From 031a592c6f125a1be075318d5be6617f3563a670 Mon Sep 17 00:00:00 2001 From: Liara Date: Fri, 10 Jul 2026 11:06:14 +0200 Subject: [PATCH 001/105] Fix bundle export errors for single-page documents not being delayed (#8618) --- crates/typst-bundle/src/lib.rs | 4 ++-- tests/ref/bundle/issue-8616-document-delayed-pages.txt | 2 ++ tests/suite/model/document.typ | 8 ++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 tests/ref/bundle/issue-8616-document-delayed-pages.txt diff --git a/crates/typst-bundle/src/lib.rs b/crates/typst-bundle/src/lib.rs index 02a159102ab9..ce11168754c8 100644 --- a/crates/typst-bundle/src/lib.rs +++ b/crates/typst-bundle/src/lib.rs @@ -305,12 +305,12 @@ fn compile_document<'a>( let num_pages = doc.pages().len(); if num_pages != 1 && matches!(format, PagedFormat::Png | PagedFormat::Svg) { - bail!( + engine.sink.delayed_error(error!( document.span(), "expected document to have a single page"; hint: "the document resulted in {num_pages} pages"; hint: "documents exported to an image format only support a single page"; - ); + )); } BundleDocument::Paged( diff --git a/tests/ref/bundle/issue-8616-document-delayed-pages.txt b/tests/ref/bundle/issue-8616-document-delayed-pages.txt new file mode 100644 index 000000000000..1be41736e294 --- /dev/null +++ b/tests/ref/bundle/issue-8616-document-delayed-pages.txt @@ -0,0 +1,2 @@ +dc5f3ead99ba15ab3ce189ab3c1a5456 doc.png +e9810b5dd33e63d639870f432f3ddec7 doc.svg diff --git a/tests/suite/model/document.typ b/tests/suite/model/document.typ index 7dcdc164b048..a6a6bdafad07 100644 --- a/tests/suite/model/document.typ +++ b/tests/suite/model/document.typ @@ -299,3 +299,11 @@ world #counted-document[A] #counted-document[B] + +--- issue-8616-document-delayed-pages bundle --- +// Test that bundle documents which are expected to have exactly one page +// perform this assertion only on the final iteration. +#import "../introspection/switch.typ": switch +#for ext in ("png", "svg") { + document("doc." + ext, switch(n => if n == 1 { pagebreak() })) +} From 10af6cb41a7639889335ddc3f86911d011e0967f Mon Sep 17 00:00:00 2001 From: Lege19 <111765057+Lege19@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:23:40 +0100 Subject: [PATCH 002/105] Make typst eval exit with code 1 when expression evaluation fails (#8623) --- crates/typst-cli/src/eval.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/typst-cli/src/eval.rs b/crates/typst-cli/src/eval.rs index 53d340086111..8057a4c1ae7d 100644 --- a/crates/typst-cli/src/eval.rs +++ b/crates/typst-cli/src/eval.rs @@ -61,7 +61,10 @@ pub fn eval(command: &'static EvalCommand) -> HintedStrResult<()> { output.introspector(), ); let errors = match &eval_result { - Err(errors) => errors.as_slice(), + Err(errors) => { + set_failed(); + errors.as_slice() + } Ok(value) => { let serialized = crate::serialize(value, command.format, command.pretty)?; From 921bb8318a54caf152acea6554d229f5596eb4a0 Mon Sep 17 00:00:00 2001 From: Max Kortge Date: Fri, 10 Jul 2026 16:15:09 +0000 Subject: [PATCH 003/105] Use stretch stack font constants for attachements above and below horizontally stretchable symbols (#8567) --- .../typst-layout/src/math/fragment/glyph.rs | 5 ++- crates/typst-layout/src/math/fragment/mod.rs | 10 ++++- crates/typst-layout/src/math/scripts.rs | 42 ++++++++++++++---- crates/typst-library/src/text/font/metrics.rs | 16 +++++++ tests/ref/html/hashes.txt | 1 + tests/ref/pdf/hashes.txt | 9 ++-- tests/ref/render/math-class-limits.png | Bin 495 -> 500 bytes ...th-stretch-horizontal-attach-constants.png | Bin 0 -> 566 bytes .../render/math-stretch-horizontal-attach.png | Bin 2251 -> 2072 bytes ...th-stretch-lr-nested-horizontal-attach.png | Bin 619 -> 601 bytes .../math-stretch-min-overlap-exceeds-max.png | Bin 586 -> 541 bytes tests/ref/svg/hashes.txt | 9 ++-- tests/suite/math/stretch.typ | 5 +++ 13 files changed, 79 insertions(+), 18 deletions(-) create mode 100644 tests/ref/render/math-stretch-horizontal-attach-constants.png diff --git a/crates/typst-layout/src/math/fragment/glyph.rs b/crates/typst-layout/src/math/fragment/glyph.rs index 77d025909953..f61864d2e8f9 100644 --- a/crates/typst-layout/src/math/fragment/glyph.rs +++ b/crates/typst-layout/src/math/fragment/glyph.rs @@ -36,6 +36,7 @@ pub struct GlyphFragment { pub(super) math_size: MathSize, pub class: MathClass, pub(super) extended_shape: bool, + pub(super) stretchable_axes: Axes, // External frame stuff. modifiers: FrameModifiers, shift: Abs, @@ -160,6 +161,7 @@ impl GlyphFragment { shaped: (FontInstance, Vec), ) -> GlyphFragment { let (font, glyphs) = shaped; + let stretchable_axes = stretch_axes(&font, glyphs[0].id); let item = TextItem { text, @@ -177,6 +179,7 @@ impl GlyphFragment { // Math math_size, class, + stretchable_axes, // Math in need of updating. extended_shape: false, italics_correction: Abs::zero(), @@ -387,7 +390,7 @@ fn decide(glyph: &GlyphFragment, stretch: &Stretch) -> Action { let font = &glyph.item.font; let id = glyph.item.glyphs[0].id; - let axes = stretch_axes(font, id); + let axes = glyph.stretchable_axes; let assess = |axis| { let Some((target, short_fall)) = resolve_stretch(glyph, stretch, axis) else { diff --git a/crates/typst-layout/src/math/fragment/mod.rs b/crates/typst-layout/src/math/fragment/mod.rs index 30a7ae308ef0..a252acecd022 100644 --- a/crates/typst-layout/src/math/fragment/mod.rs +++ b/crates/typst-layout/src/math/fragment/mod.rs @@ -7,11 +7,12 @@ use std::fmt::Debug; use ttf_parser::GlyphId; use typst_library::foundations::StyleChain; use typst_library::introspection::Tag; -use typst_library::layout::{Abs, Corner, Em, Frame, FrameItem, Point, Size}; +use typst_library::layout::{Abs, Axis, Corner, Em, Frame, FrameItem, Point, Size}; use typst_library::math::MathSize; use typst_library::math::ir::MathProperties; use typst_library::text::{FontInstance, TextElem}; use typst_library::visualize::{FixedStroke, Paint}; +use typst_utils::Get; use unicode_math_class::MathClass; use self::glyph::kern_at_height; @@ -126,6 +127,13 @@ impl MathFragment { } } + pub fn is_stretchable(&self, axis: Axis) -> bool { + match self { + Self::Glyph(glyph) => glyph.stretchable_axes.get(axis), + _ => false, + } + } + pub fn is_text_like(&self) -> bool { match self { Self::Glyph(glyph) => !glyph.extended_shape, diff --git a/crates/typst-layout/src/math/scripts.rs b/crates/typst-layout/src/math/scripts.rs index 3767de281403..997c2ad0c38a 100644 --- a/crates/typst-layout/src/math/scripts.rs +++ b/crates/typst-layout/src/math/scripts.rs @@ -46,6 +46,7 @@ pub fn layout_scripts( item.base.set_stretch_relative_to(relative_to_width, Axis::X); let base = ctx.layout_into_fragment(&item.base, styles)?; + let stretchy = base.is_stretchable(Axis::X); let fragments = [ layout!(top_left), @@ -56,7 +57,14 @@ pub fn layout_scripts( layout!(bottom_right), ]; - layout_attachments(ctx, props, item.base.styles().unwrap_or(styles), base, fragments) + layout_attachments( + ctx, + props, + item.base.styles().unwrap_or(styles), + base, + stretchy, + fragments, + ) } /// Lays out a [`PrimesItem`]. @@ -94,6 +102,7 @@ fn layout_attachments( props: &MathProperties, styles: StyleChain, base: MathFragment, + stretchy: bool, [tl, t, tr, bl, b, br]: [Option; 6], ) -> SourceResult<()> { let (font, size) = base.font(ctx, styles); @@ -110,7 +119,7 @@ fn layout_attachments( // Calculate the distance from the base's baseline to the top attachment's // and bottom attachment's baseline. let (t_shift, b_shift) = - compute_limit_shifts(&font, size, &base, [t.as_ref(), b.as_ref()]); + compute_limit_shifts(&font, size, &base, stretchy, [t.as_ref(), b.as_ref()]); // Calculate the final frame height. let ascent = base @@ -291,6 +300,7 @@ fn compute_limit_shifts( font: &FontInstance, font_size: Abs, base: &MathFragment, + stretchy: bool, [t, b]: [Option<&MathFragment>; 2], ) -> (Abs, Abs) { // `upper_gap_min` and `lower_gap_min` give gaps to the descender and @@ -298,15 +308,31 @@ fn compute_limit_shifts( // `lower_drop_min` give gaps to each limit's baseline (see the // MathConstants table in the OpenType MATH spec). let t_shift = t.map_or_default(|t| { - let upper_gap_min = font.math().upper_limit_gap_min.at(font_size); - let upper_rise_min = font.math().upper_limit_baseline_rise_min.at(font_size); - base.ascent() + upper_rise_min.max(upper_gap_min + t.descent()) + if stretchy { + let top_shift_up = font.math().stretch_stack_top_shift_up.at(font_size); + let gap_above_min = font.math().stretch_stack_gap_above_min.at(font_size); + let gap = (top_shift_up - base.ascent() - t.descent()).max(gap_above_min); + base.ascent() + gap + t.descent() + } else { + let upper_gap_min = font.math().upper_limit_gap_min.at(font_size); + let upper_rise_min = font.math().upper_limit_baseline_rise_min.at(font_size); + base.ascent() + upper_rise_min.max(upper_gap_min + t.descent()) + } }); let b_shift = b.map_or_default(|b| { - let lower_gap_min = font.math().lower_limit_gap_min.at(font_size); - let lower_drop_min = font.math().lower_limit_baseline_drop_min.at(font_size); - base.descent() + lower_drop_min.max(lower_gap_min + b.ascent()) + if stretchy { + let bottom_shift_down = + font.math().stretch_stack_bottom_shift_down.at(font_size); + let gap_below_min = font.math().stretch_stack_gap_below_min.at(font_size); + let gap = + (bottom_shift_down - base.descent() - b.ascent()).max(gap_below_min); + base.descent() + gap + b.ascent() + } else { + let lower_gap_min = font.math().lower_limit_gap_min.at(font_size); + let lower_drop_min = font.math().lower_limit_baseline_drop_min.at(font_size); + base.descent() + lower_drop_min.max(lower_gap_min + b.ascent()) + } }); (t_shift, b_shift) diff --git a/crates/typst-library/src/text/font/metrics.rs b/crates/typst-library/src/text/font/metrics.rs index 017c05ff3923..9b8bf6bebfd2 100644 --- a/crates/typst-library/src/text/font/metrics.rs +++ b/crates/typst-library/src/text/font/metrics.rs @@ -167,6 +167,10 @@ pub struct MathConstants { pub stack_bottom_display_style_shift_down: Em, pub stack_gap_min: Em, pub stack_display_style_gap_min: Em, + pub stretch_stack_top_shift_up: Em, + pub stretch_stack_bottom_shift_down: Em, + pub stretch_stack_gap_above_min: Em, + pub stretch_stack_gap_below_min: Em, pub fraction_numerator_shift_up: Em, pub fraction_numerator_display_style_shift_up: Em, pub fraction_denominator_shift_down: Em, @@ -262,6 +266,14 @@ impl MathConstants { stack_gap_min: font.to_em(constants.stack_gap_min().value), stack_display_style_gap_min: font .to_em(constants.stack_display_style_gap_min().value), + stretch_stack_top_shift_up: font + .to_em(constants.stretch_stack_top_shift_up().value), + stretch_stack_bottom_shift_down: font + .to_em(constants.stretch_stack_bottom_shift_down().value), + stretch_stack_gap_above_min: font + .to_em(constants.stretch_stack_gap_above_min().value), + stretch_stack_gap_below_min: font + .to_em(constants.stretch_stack_gap_below_min().value), fraction_numerator_shift_up: font .to_em(constants.fraction_numerator_shift_up().value), fraction_numerator_display_style_shift_up: font @@ -358,6 +370,10 @@ impl MathConstants { stack_bottom_display_style_shift_down: Em::zero(), stack_gap_min: 3.0 * metrics.underline.thickness, stack_display_style_gap_min: 7.0 * metrics.underline.thickness, + stretch_stack_top_shift_up: Em::zero(), + stretch_stack_bottom_shift_down: Em::zero(), + stretch_stack_gap_above_min: Em::zero(), + stretch_stack_gap_below_min: Em::zero(), fraction_numerator_shift_up: Em::zero(), fraction_numerator_display_style_shift_up: Em::zero(), fraction_denominator_shift_down: Em::zero(), diff --git a/tests/ref/html/hashes.txt b/tests/ref/html/hashes.txt index bb3b92040ddc..57632a12d84f 100644 --- a/tests/ref/html/hashes.txt +++ b/tests/ref/html/hashes.txt @@ -343,6 +343,7 @@ c6f4f1caf46009fe16fc2746ab283b52 math-stretch-complex 33c9ee459f9b6de39ba073fba20efe43 math-stretch-explicit-mixed 132ea149920a76e79961b39b9901f268 math-stretch-horizontal 572fdb0e7106f0aec447a1b4ede2abba math-stretch-horizontal-attach +7d2717c7246a2cef811fd6248f65a043 math-stretch-horizontal-attach-constants 311b2e558fb4fffadafac7594f03eea1 math-stretch-lr-nested-horizontal b8025bdcc95342f0076e97bad479719f math-stretch-lr-nested-horizontal-attach dac421d6f72d8b3a793f710dce214c82 math-stretch-lr-nested-vertical diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index 215e5af54d79..c23b353cdf9a 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -1109,7 +1109,7 @@ c6d8d02f23dd27b394211f3100465b15 math-cases-linebreaks-trailing ea1c5d0877e6dc21d3d02980c2412e09 math-class-chars 6fa87c49a63f35e1cf8e757f36850329 math-class-content 16cd1ac07d0a72392b31412902558fdc math-class-exceptions -a22d2db7e0135d5f9126c30f3c19494e math-class-limits +69ab6637ff5264e740895c8a8d007c1f math-class-limits fc2cd15c2c8976bd368b00dc078069b3 math-class-nested a119e5653fd04be9bcd89d07bd98c96c math-class-not-recursive 1b24d9c62649d487de87348d90a07778 math-common-symbols @@ -1305,12 +1305,13 @@ e59997145effaf8ed480c80fce67be7d math-stretch-explicit-mixed 665c9a4ddf38847fa1bd92d33a05e166 math-stretch-fallback 78a526a92f82b34e90c4c189c5bd7bf1 math-stretch-fallback-explicit 0321378b783573d752b400db14b2ab81 math-stretch-horizontal -5429a6ad3c8fb747ede6fa7f1b2d7171 math-stretch-horizontal-attach +f3d0a41cff2270e0fcb7bdb135af9166 math-stretch-horizontal-attach +27c88dc8c6ff2f9ac0617baee0957e62 math-stretch-horizontal-attach-constants bf4d73b61fec58823aa72e796b28d614 math-stretch-lr-nested-horizontal -235efdbd5fdaa17f807176bf1613c799 math-stretch-lr-nested-horizontal-attach +02561efbdca8574b5ef9152b90dcac88 math-stretch-lr-nested-horizontal-attach 6a17f4daa45daba15045c9ee9d75263d math-stretch-lr-nested-vertical fff143ce58ee044f38efdf0def61dd83 math-stretch-lr-nested-vertical-attach -eed21a1f575339ea9c11e663d2eba8f2 math-stretch-min-overlap-exceeds-max +a5a280d1a72e0f73082d6c9f758effc7 math-stretch-min-overlap-exceeds-max 57fbcf389872ef26a04745390a5c021d math-stretch-nested a50b9794695c043d24df3b1ed367227f math-stretch-nested-horizontal-attach 0ca1280877c66a671a42812a774b46e5 math-stretch-nested-vertical-attach diff --git a/tests/ref/render/math-class-limits.png b/tests/ref/render/math-class-limits.png index 140acf956ffb2d18beb82243b0581045d92b93d3..2003624f6827e9d2fe18f91b2cb00631d109c689 100644 GIT binary patch delta 474 zcmV<00VV$L1M~xsB!9a}L_t(|+U?a{D}!+u25=upId$wrPAE<}Ac;cRl!y~=CsN*u zB5#?tnzxcnScuKL!qQ%pZCY!shv{jy$DUg~pTN=oD!Z;P@aunfU-!R6Kq(HSqaO8Uk;f z&Rjv@c4>(~;D4)&)dy}hVZww76IKD6J`gN+Q#Jf~ZN*)kQVgdKt*lkVF{AD_M8p6r zmscb+>|BD>Y3_0L1Oo4PY%360OrA`_gb5S=18i=CVCl`MhNH_?rpkhfVIyJ1)2d@e zZAUFvfIU_LXB`_a5O^{=9)iGjv-TDOr!K>B{!WAmt5b%vKSmA#-?^J-YY=ciD)lb} zEV)Y25OBwC+v}ki{)}qa|8#vOGN9E#>ZJASwiE)EMbAu>my0lA!bJoB0&M~QuH(-- QSpWb407*qoM6N<$g1H&l9RL6T delta 469 zcmV;`0V@9V1MdTnB!9L^L_t(|+U?azOM+n(1>k;A(6)87Y11+yYEg@5(OeMKt{K`i zK~X_-C`1-iG)slqh=FP4kfer)h$%=8ecuhfU(o73kmvk?hxc;NeTxK?BEp0T6Gp(M zAPD~I&Z>qtMvT-}U|yW-2w`BeLnM7*)s@3(5$XGGz|^TV(|_cHz*f8Elb4GyVZww7 z|36q;9G-6e0Kl%)+>$p38FgJ)69BvrDwYidY~Q&tMOh~$uy%NvhQQ;I&IAN*k$OJ_ zw%fLS+-kyv3ko(*PYm1w;Hc-k#}|i;dhiS~>SK3lVoL+S4)fLKGwY-Vwmrllu;p^* z4gxnzqaOm_U4O4WajOXvCQO*H3Rt&;VCm9T!^wLiTa_`zaO~8`dRZ7V>h8RV@ciqf3j()GjS~U~u5CX4PJ{^;3Qqj8);?Q}fFGqI5k$c9Q!W_< z+<)2_-xGP)Rl_GY=X)ZfZ&i@3PWEQoQV3X|+8^`sfDk53xM1L4&6575X7_Oq00000 LNkvXXu0mjf+_~D3 diff --git a/tests/ref/render/math-stretch-horizontal-attach-constants.png b/tests/ref/render/math-stretch-horizontal-attach-constants.png new file mode 100644 index 0000000000000000000000000000000000000000..24af62e8a1d257876e06aad2a21cf50a9cc54760 GIT binary patch literal 566 zcmV-60?GY}P)ODHq?4NP#!og=ie_-6Y&1ZVd@Fy;Tc>jhlwGBg}kwY{UJ#nd4kpES8fpi5#%qIZIzE?)& zpmzBheHOucnED4u&foq&4Q!1O|V1orXB)QA#=TU!^JMPgQ-IkQtwmK;>qW4mH?>_Tfktm=}92x zkk=Niz(zUA#~{zdD2{5$XaLJYy&KhtSmfLyCt^$|lbdCyszTfFQf zrr4+E-?T3>tFUC#1Ka6PR{y~!Hd-H#T0Cm;sKxXI05uQXKu>rv7ytkO07*qoM6N<$ Eg4MDS=Kufz literal 0 HcmV?d00001 diff --git a/tests/ref/render/math-stretch-horizontal-attach.png b/tests/ref/render/math-stretch-horizontal-attach.png index 7d45f0167bca86f8260785e881b2f67a60ec645a..ece11603592548f88f662ea6baa6acd704b88a36 100644 GIT binary patch literal 2072 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 Z{tIeIVikydn=JqU002ovPDHLkV1la^XSDzT diff --git a/tests/ref/render/math-stretch-lr-nested-horizontal-attach.png b/tests/ref/render/math-stretch-lr-nested-horizontal-attach.png index 0e73a5f791d37556a500b2ab735821ee2142d71f..a55de19fd17145834ecf1d9ab3cf044d5b8f7432 100644 GIT binary patch delta 589 zcmV-T07_jw5;bU@gS9n6z*LIb)-@UkHI+gO9@5IvR?~wnZd*eVb(_R!&_v_9 zJZERf$&1V|D)PQL{NS6zj|Yz(*e1B3G@%&9C`R!)h@C;=W`B8;u>AkY@N$C$iJt`M z36ZaACeCNwRP8Lwag`x&Xa8pZ&|^|o)J#0BxiO0xOW9zE6|^xeO3xeZt@i5*F4qQ! zV{z`(8W)zMRQ%&EXQZ36<8$Mt^|wm#;YSbSzxfA@3(+v}BnM|zibuX*KX%!#-*T}_ z-ai4B-0R^1g@59-Zh1}0xr}uiXr`=O8@O?%tYxn{ao%^}1ZMyid!X2v2AF&YE04E; zCY%KQX&JxOC`R$=M2P=|zb_E#?&%GN0{vYBT_hsY;l!7ExsGHy32{6m(}|1AlkGB{ zxOk%Kk?DlRi*sLEWI9o?-U+39OEMjPt5J+%e2--(#((8HgE%!J(}|1g!;fS-adF&k zmFa}Vt7C~9GM%VcZ-?z`OA;Nv$0$az#Q$oX7?(>}TpQvvW;x?V<7Tv|R6KKsv(Lv+ z>i7UWnfnVW#pyKD_n&K7!GZ* zOMRWr9J!F03?I$=%;8CYmnWM{w#l}@KGA|<3}YC>dl0VoD}N1AzZA;aS1SLi;!#|R zn;fq)Atekst3lbeDBESKoNZ;SpX4kOZ_0V%xZYr~s8cq6P>rtZAzdYBNZ0h4=DS>l ztIVGUnSBWW#!X9VINnZGebPmpxuF3g)%8JUcyg_S+NhnP9!)xdQhW6}AThidFx&IQ z^O|`a2=C*u^M61wjHlP+g>z>r^>Zq~=SxsLG7Avtfplvg2#

b!-D)nHa|KPJ)W7 z*Y&{dy?4Lc<8?oD_BoZH$c9gRkS|wdUStyp`)`SC{NdS|vmzUJ*uG=7iEP~AVH+GG z8*exfgx|iD$cFD~3}blT{+$X|hMerm((>}sN|yd8h<|Lm4x2GGIy4fBXyLFH35}>x zHD-!zhv{-n4Tx;~;lBfyMK=DhVLL=N?r{3K*(S2_hNmw=sr8%4hObNvW0?QzEk1D* zT&`)R@&a?V1i<)Sl^ULKF#GOO{p)-KjI9$%nc>pMlhmnXC-vL0eqb1CRA$&~9^E3U z-DY2JEm+Us6`;)Z_H;`OkFUb@1>)gt=pd@Rfcj}@@aHA0Uyv8h^;PSEaeyz+p!jGG tVC)^FeR&{+Cn4d-&&C+WFov5w{2!qzOC8&NvG)J~002ovPDHLkV1g++Dk=Z~ diff --git a/tests/ref/render/math-stretch-min-overlap-exceeds-max.png b/tests/ref/render/math-stretch-min-overlap-exceeds-max.png index 1de22c62c61bcda5d1cd6fd9bddb4ef5f519888c..accd18a4612ba515942535e9ee34efc3641291b2 100644 GIT binary patch delta 516 zcmV+f0{i{S1f2wsBYy$pNklf~WibpE~h> z&CwUP=k$I#a{c}GqgR2V(f_Y~%=xtG)7CR*j$VGh_5F?`SAYLs+WWs??dhBUj-30w z`sP7WEZ#Bo|D9vcFBbgG{GahZ<;;cllYhSdKRaR7v9-Gj_uN`=wfFRg-2WjD6M#y+ zHun9``Jenh?7r`VG$0KW{kM0*-If;z7frr5?NZ{6xPK&Ae5v^VoAB@RqJL%s4c!*- zY-al9X&=u{Y=3-kX?oSCmAmfu-d&Xhl*s`qjrsrVIgo_{7u1|@E#tSZDzWEejwa?H(NLDhp(oX#)ujtKi@uY0uzbXL%scL>EY?*#rj%NP7nd zJ1PQs9{Rz$8gB-~$3OeN4@^+M{ymzVMlBw-c+}$IV=(|Iiqn|nP$yFW0000kE`exuN3jMHXHLW%})|T`_C3W_a zL7ly{mvVTX)N-}=Uf7e&tOnb1?WlEZZJN_rw2g?SGji72-3^6M-%bUBocn?6zOL)b z4<4SU1vY6~WUvgD!7}(igH!(gTYq+5bK{qLY}+Vv`Uv~B9e?irOdA_k>y?SW0DsZ~ z3_<$YdboZErlXDc6D%_pcs>EaWaZ5O_c@WPK1^n@Rl@5Prgpy;6@=tWHCi1_WI-GU zT_`i#OiXv@;C=D3U@KzKXk75W=>NBDlu%lRRP#QG^DZOpRWA8xtOI_*78mjD0&07*qoM6N<$f~Bew AQ~&?~ diff --git a/tests/ref/svg/hashes.txt b/tests/ref/svg/hashes.txt index 3bc483b600a6..fe75ad89b644 100644 --- a/tests/ref/svg/hashes.txt +++ b/tests/ref/svg/hashes.txt @@ -1105,7 +1105,7 @@ ef3e59e3afcd3e41ba7cb2956f03b5ff math-cases-linebreaks 874d5aa6a36908463785177bc27bc4d1 math-class-chars 2f499530c7478f34e9a2298d2be4132b math-class-content 2b85489983932cc3ab51adc04baccf53 math-class-exceptions -dc1e2ee5c1c13ce19580e9c655011be4 math-class-limits +30e19ae82443b91203ce1cd129884e09 math-class-limits 69cc29ae64a5827f4acb289bdfabafa0 math-class-nested 796da868fa1793cc51bd7796bb3eceb1 math-class-not-recursive 0a202a0825ac65047622372db0616b01 math-common-symbols @@ -1301,12 +1301,13 @@ cf031060bea7acc11ca9ad2ea198703b math-stretch-explicit-mixed 76f5f06312731e7c6e58a11bd6c512a1 math-stretch-fallback f3ccdd06af639f81a10e7789194fd866 math-stretch-fallback-explicit b6144b5c4078db0be6cb768b6431035c math-stretch-horizontal -c82ffd218d5523f43569c0b15c91c514 math-stretch-horizontal-attach +71dfba701816929954174d9e25fd74bd math-stretch-horizontal-attach +966704d2567040f133e84831707e9393 math-stretch-horizontal-attach-constants 44efabd08e811679a40e2563097d6070 math-stretch-lr-nested-horizontal -21cd7046956e394f0ae750d05454104e math-stretch-lr-nested-horizontal-attach +231edd8f485a737f6afb2446e6f90e85 math-stretch-lr-nested-horizontal-attach 21136a330e3fefb087624b1ebdba230d math-stretch-lr-nested-vertical decc95fd871348fa1ab7b62cb3372a92 math-stretch-lr-nested-vertical-attach -9d31acb01241cc65db68399ee93dc640 math-stretch-min-overlap-exceeds-max +dc0fa1453c7672ce9fcfa3c08c5dcc4c math-stretch-min-overlap-exceeds-max ddaf7dcd0c471d6b88b40946d6f4875e math-stretch-nested b4442034ae62edcf19e6a8fffbb17eab math-stretch-nested-horizontal-attach f8306f2be27d4317c79f88b4a1bf56c5 math-stretch-nested-vertical-attach diff --git a/tests/suite/math/stretch.typ b/tests/suite/math/stretch.typ index dc8fa1842752..9336101690c7 100644 --- a/tests/suite/math/stretch.typ +++ b/tests/suite/math/stretch.typ @@ -31,6 +31,11 @@ $ P = Q U stretch(equiv)^(forall i)_"Chern-Weil" V stretch(equiv, size: #(120% + 2mm))^(forall i)_"Chern-Weil" W $ +--- math-stretch-horizontal-attach-constants paged html --- +#show math.equation: set text(font: "Asana Math") +$ a stretch(->)^a b ->^a c $ +$ a stretch(->)_p b ->_p c $ + --- math-stretch-vertical-attach paged html --- // Test vertical stretch interactions with attachments. $arrow.t$ From 4eee706a7374b7c4945e94155f6e0be102f3992f Mon Sep 17 00:00:00 2001 From: Max Kortge Date: Mon, 13 Jul 2026 08:03:57 +0000 Subject: [PATCH 004/105] Prevent footnote in a non-sticky frame causing preceding sticky frames to become orphaned (#8592) --- crates/typst-layout/src/flow/distribute.rs | 64 ++++++++++-------- tests/ref/pdf/hashes.txt | 2 + .../flow-heading-no-orphan-with-footnote.png | Bin 0 -> 935 bytes ...ow-heading-orphan-forced-with-footnote.png | Bin 0 -> 732 bytes tests/ref/svg/hashes.txt | 2 + tests/suite/layout/flow/orphan.typ | 16 +++++ 6 files changed, 55 insertions(+), 29 deletions(-) create mode 100644 tests/ref/render/flow-heading-no-orphan-with-footnote.png create mode 100644 tests/ref/render/flow-heading-orphan-forced-with-footnote.png diff --git a/crates/typst-layout/src/flow/distribute.rs b/crates/typst-layout/src/flow/distribute.rs index 3538a596e296..9143b1595227 100644 --- a/crates/typst-layout/src/flow/distribute.rs +++ b/crates/typst-layout/src/flow/distribute.rs @@ -385,38 +385,36 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { sticky: bool, breakable: bool, ) -> FlowResult<()> { - if sticky { - // If the frame is sticky and we haven't remembered a preceding - // sticky element, make a checkpoint which we can restore should we - // end on this sticky element. - // - // The first sticky block within consecutive sticky blocks - // determines whether this group of sticky blocks has stickiness - // disabled or not. - // - // The criteria used here is: if migrating this group of sticky - // blocks together with the "attached" block can't improve the lack - // of space, since we're at the start of the region, then we don't - // do so, and stickiness is disabled (at least, for this region). - // Otherwise, migration is allowed. - // - // Note that, since the whole region is checked, this ensures sticky - // blocks at the top of a block - but not necessarily of the page - - // can still be migrated. - if self.sticky.is_none() - && *self.stickable.get_or_insert_with(|| self.regions.may_progress()) - { - self.sticky = Some(self.snapshot()); - } - } else if !frame.is_empty() { - // If the frame isn't sticky, we can forget a previous snapshot. We - // interrupt a group of sticky blocks, if there was one, so we reset - // the saved stickable check for the next group of sticky blocks. - self.sticky = None; - self.stickable = None; + // If the frame is sticky and we haven't remembered a preceding sticky + // element, make a checkpoint which we can restore should we end on + // this sticky element. + // + // The first sticky block within consecutive sticky blocks determines + // whether this group of sticky blocks has stickiness disabled or not. + // + // The criteria used here is: if migrating this group of sticky blocks + // together with the "attached" block can't improve the lack of space, + // since we're at the start of the region, then we don't do so, and + // stickiness is disabled (at least, for this region). Otherwise, + // migration is allowed. + // + // Note that, since the whole region is checked, this ensures sticky + // blocks at the top of a block - but not necessarily of the page - can + // still be migrated. + if sticky + && self.sticky.is_none() + && *self.stickable.get_or_insert_with(|| self.regions.may_progress()) + { + self.sticky = Some(self.snapshot()); } // Handle footnotes. + // + // This must happen before we forget a previous sticky snapshot below. + // If a non-sticky frame's footnote doesn't fit, the frame and any + // preceding sticky blocks attached to it need to migrate to the next + // region together. Resetting the sticky state first would then strand + // those sticky blocks in this region. self.composer.footnotes( &self.regions, &frame, @@ -425,6 +423,14 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { true, )?; + if !sticky && !frame.is_empty() { + // If the frame isn't sticky, we can forget a previous snapshot. We + // interrupt a group of sticky blocks, if there was one, so we reset + // the saved stickable check for the next group of sticky blocks. + self.sticky = None; + self.stickable = None; + } + // Push an item for the frame. self.regions.size.y -= frame.height(); self.flush_tags(); diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index c23b353cdf9a..7c13f8fa4c1c 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -239,6 +239,8 @@ f9d1863928378f0923b4608b2ad9e437 flow-first-region-placed 2e00227818d9634af985cb5a195dc101 flow-first-region-zero-sized-item acbd64702bef7a7e2e84db8eaaaeef6a flow-fr ce021acd6263989175451e7e120ced38 flow-heading-no-orphan +7ba3685ff8290483c83165e82bc9aba2 flow-heading-no-orphan-with-footnote +51f3c8b3eb6a1b22371dce8c192a833a flow-heading-orphan-forced-with-footnote 2375d0e0148d07153cb082836c213ba1 flow-par-no-orphan-and-widow-lines f8a5a605c5c664d613b105e67aa6bb19 flow-widow-forced b2bdc8e35c5748f9f57446071d3f7d21 fold-vec-order-meta diff --git a/tests/ref/render/flow-heading-no-orphan-with-footnote.png b/tests/ref/render/flow-heading-no-orphan-with-footnote.png new file mode 100644 index 0000000000000000000000000000000000000000..615a534ce8946910909d8c2f0531382aba329d5e GIT binary patch literal 935 zcmV;Y16cftP)~i~arm<>lq|_4WAp_@SYpiHV8!_V(-R>)_zvJ3BiX8X71lC>IwOGcz-#rKJuI z4wRIXYinx(0RdiKUR709-rn9qLPDURpr4RDy&(F_zczD*<)@f;J!otE-R8$fY5(5JRU|?X|+uQB! z?c?L)=H}*TXlTR3!@0S+Dk>_ju(+(QwuFX`kCB;$hmWzrXD4?5e7&tE;O81qB8M2BM;(o12^H=;&ElS@iVuI5;?ffPk2o zm}O;U6B83aKtPg`lE=r#Jv}`!F)>R^OaA`;h=_=Vg@xqg{vGP4P@UEFN=Nm*sCd+sU7wPsn zcqrB7I`HtKabf3t9NfLLBkIE5{d??;He#>mW6Q4Hdup(j?+3O;Ls%Oc1hz(VF*dKW zdkJF;qUilQOeIsvIK-+_AL3eYk^lA>AIYPKagjc92p1W)6mjtE>67lII4HpO4(w&W z$Jp1t4s$<-$Ju}2phiVRL_|bHL_|bHq>}w{qBk!hlOYZzJiK}Rs?gG$z+OWwH*!tb zn_kP!+)R(~jKZz{yw4Ml_0<<$!hFho!(3fGRlQ&GSewaT2nv zO+IJ5H``G32w%NI*II{g&85q1SfB8@P6L&5kAIDhYQ9CLlE3#(nSQe8nlk_Z002ov JPDHLkV1n6u>jMA) literal 0 HcmV?d00001 diff --git a/tests/ref/render/flow-heading-orphan-forced-with-footnote.png b/tests/ref/render/flow-heading-orphan-forced-with-footnote.png new file mode 100644 index 0000000000000000000000000000000000000000..cd7067d7c309ab0c027c01bf47acd31dcb58be97 GIT binary patch literal 732 zcmV<20wev2P)lq0qobOdn)UVd=jZ2eaBzHl zeB0aG0RaKR!op!;VQFb;%gf8b!NEvKNOyO4(9qC1IXRP)lRiE^0s;b?o13kzt#Wd5 z)6>&CJ3GqC%D%wJKtf8#$iJq)z{zC)!p9S-mb2$kCBO zS5Qz;Vq#)2FfgyLuZD((#Kgqa)z!wv#))_Z$&VE+@$vEg{{D!Fi1+vRCdfD#0003yNklEU!b`BD;5vG^$!06GQ2 zvonCJqg{V^Y8q$_NA-tChJm(7gC#Ls`6K`8ia^*rImX)ZyZ-Q7;uX&c(=PY8)G%NV zW9wIGNEqF~hZ_PaH`7Uogtsy;d6Doo_LC6Mva`E)3IP@@{&a?Qav84OojIV O0000 Date: Mon, 13 Jul 2026 10:04:37 +0200 Subject: [PATCH 005/105] Remove note about nested artifacts from `artifact.kind` documentation (#8536) --- crates/typst-library/src/pdf/accessibility.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs index 8146e24b7fae..814058242970 100644 --- a/crates/typst-library/src/pdf/accessibility.rs +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -61,18 +61,14 @@ pub enum ArtifactKind { Footer, /// Text or graphics in the back- or foreground of all pages. Watermark, - /// Page numbers. Note that if your page numbers are contained in a footer - /// or header instead, the whole header or footer should an artifact of the - /// appropriate type. + /// Page numbers. PageNumber, /// Line or paragraph numbers. LineNumber, /// Placeholders for areas in which there was content in another rendition /// of the document which has since been removed. Redaction, - /// Bates numbering. Note that if your Bates numbering is contained in a - /// footer or header instead, the whole header or footer should an artifact - /// of the appropriate type. + /// Bates numbering. Bates, /// Not part of the document, but rather the page it is printed on. An /// example would be cut marks or color bars. From ef2aba9a52ea8885d77f7937f6b1ce0da733c3c2 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 13 Jul 2026 10:23:32 +0200 Subject: [PATCH 006/105] Adjust examples for text decoration `background` parameters (#8641) --- crates/typst-library/src/text/deco.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs index dcf644a2362f..de24bd547fa3 100644 --- a/crates/typst-library/src/text/deco.rs +++ b/crates/typst-library/src/text/deco.rs @@ -59,9 +59,9 @@ pub struct UnderlineElem { /// Whether the line is placed behind the content it underlines. /// /// ```example - /// #set underline(stroke: (thickness: 1em, paint: maroon, cap: "round")) - /// #underline(background: true)[This is stylized.] \ - /// #underline(background: false)[This is partially hidden.] + /// #set underline(stroke: aqua + 5pt, evade: false) + /// #underline(background: true)[Fully visible.] \ + /// #underline(background: false)[Partially hidden.] /// ``` #[default(false)] pub background: bool, @@ -133,9 +133,9 @@ pub struct OverlineElem { /// Whether the line is placed behind the content it overlines. /// /// ```example - /// #set overline(stroke: (thickness: 1em, paint: maroon, cap: "round")) - /// #overline(background: true)[This is stylized.] \ - /// #overline(background: false)[This is partially hidden.] + /// #set overline(stroke: aqua + 5pt) + /// #overline(background: true)[Fully visible.] \ + /// #overline(background: false)[Partially hidden.] /// ``` #[default(false)] pub background: bool, @@ -192,7 +192,7 @@ pub struct StrikeElem { /// Whether the line is placed behind the content. /// /// ```example - /// #set strike(stroke: red) + /// #set strike(stroke: red + 2pt) /// #strike(background: true)[This is behind.] \ /// #strike(background: false)[This is in front.] /// ``` From 824fc9a157bbb05d134af5774b10a5f380b5d985 Mon Sep 17 00:00:00 2001 From: Lege19 <111765057+Lege19@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:29:14 +0100 Subject: [PATCH 007/105] Add `background` parameter to `math.cancel` (#8622) Co-authored-by: Laurenz --- crates/typst-layout/src/math/cancel.rs | 12 ++++++++++-- crates/typst-library/src/math/cancel.rs | 11 +++++++++++ crates/typst-library/src/math/ir/item.rs | 4 ++++ crates/typst-library/src/math/ir/resolve.rs | 2 ++ tests/ref/pdf/hashes.txt | 1 + tests/ref/render/math-cancel-background.png | Bin 0 -> 406 bytes tests/ref/svg/hashes.txt | 1 + tests/suite/math/cancel.typ | 4 ++++ 8 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/ref/render/math-cancel-background.png diff --git a/crates/typst-layout/src/math/cancel.rs b/crates/typst-layout/src/math/cancel.rs index c34ead78cc1e..d80b2dc05fcb 100644 --- a/crates/typst-layout/src/math/cancel.rs +++ b/crates/typst-layout/src/math/cancel.rs @@ -42,7 +42,11 @@ pub fn layout_cancel( // The origin of our line is the very middle of the element. let center = body_size.to_point() / 2.0; - body.push_frame(center, first_line); + if item.background { + body.prepend_frame(center, first_line); + } else { + body.push_frame(center, first_line); + } if item.cross { // Draw the second line. @@ -57,7 +61,11 @@ pub fn layout_cancel( props.span, )?; - body.push_frame(center, second_line); + if item.background { + body.prepend_frame(center, second_line); + } else { + body.push_frame(center, second_line); + } } ctx.push( diff --git a/crates/typst-library/src/math/cancel.rs b/crates/typst-library/src/math/cancel.rs index 80746d990396..51b63223cd36 100644 --- a/crates/typst-library/src/math/cancel.rs +++ b/crates/typst-library/src/math/cancel.rs @@ -95,6 +95,17 @@ pub struct CancelElem { ..Default::default() })] pub stroke: Stroke, + + /// Whether the line is placed behind the content. + /// + /// ```example + /// >>> #set page(width: 140pt) + /// #set math.cancel(stroke: red + 2pt) + /// $ cancel("behind", background: #true) \ + /// cancel("in front", background: #false) $ + /// ``` + #[default(false)] + pub background: bool, } /// Defines the cancel line. diff --git a/crates/typst-library/src/math/ir/item.rs b/crates/typst-library/src/math/ir/item.rs index 5fc24b32851e..7d629afefa04 100644 --- a/crates/typst-library/src/math/ir/item.rs +++ b/crates/typst-library/src/math/ir/item.rs @@ -796,6 +796,8 @@ pub struct CancelItem<'a> { pub cross: bool, /// Whether to invert the angle of the first line. pub invert_first_line: bool, + /// Whether to draw the line behind the main content. + pub background: bool, /// The angle of the line. pub angle: Smart, } @@ -810,6 +812,7 @@ impl<'a> CancelItem<'a> { stroke: FixedStroke, cross: bool, invert_first_line: bool, + background: bool, angle: Smart, styles: StyleChain<'a>, span: Span, @@ -821,6 +824,7 @@ impl<'a> CancelItem<'a> { stroke, cross, invert_first_line, + background, angle, })); MathComponent { kind, props, styles }.into() diff --git a/crates/typst-library/src/math/ir/resolve.rs b/crates/typst-library/src/math/ir/resolve.rs index 6178705b3cdb..4fe736e70680 100644 --- a/crates/typst-library/src/math/ir/resolve.rs +++ b/crates/typst-library/src/math/ir/resolve.rs @@ -665,6 +665,7 @@ fn resolve_cancel<'a>( let cross = elem.cross.get(styles); let angle = elem.angle.get_ref(styles); let invert_first_line = !cross && invert; + let background = elem.background.get(styles); ctx.push(CancelItem::create( body, @@ -672,6 +673,7 @@ fn resolve_cancel<'a>( stroke, cross, invert_first_line, + background, angle.clone(), styles, elem.span(), diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index 7c13f8fa4c1c..77ee3e2850de 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -1097,6 +1097,7 @@ c02f19655e64bad9d823d02b6ac0af69 math-call-shadowed-builtin 888989368f1b77cb455318311d1fd6f1 math-call-spread-shorthand-clash 143007913a7d1a648bf888761cb05a5a math-cancel-angle-absolute a80a0d493b95670928f450696fe557b0 math-cancel-angle-func +4c21d60ad7f27019fdc5c83791c87f3f math-cancel-background f109bd54fad79708ccdfb39900b01c55 math-cancel-cross daedea226c3ec806ded5720c59416c6f math-cancel-customized 76e2be59e17f63a2fe248d056da97889 math-cancel-display diff --git a/tests/ref/render/math-cancel-background.png b/tests/ref/render/math-cancel-background.png new file mode 100644 index 0000000000000000000000000000000000000000..80c630048151e63191fddeac8e666d467a67b784 GIT binary patch literal 406 zcmV;H0crk;P)aG4|8QtVMn;~Vp1!`m|L5g~hK8&^`Jw;-0GCNbK~#9!?bNpt!Y~j7(3QYP z&N=67447bo^#8v_p#oQQ5;M%bX0O}1X2BTycfv~vxOcneh%#_5x)70sLrD{pDN!2s z9fH-;T-rphKO87ncRKFqzr z-)nP Date: Mon, 13 Jul 2026 19:52:57 +0800 Subject: [PATCH 008/105] Fix some typos in array docs (#8633) --- crates/typst-library/src/foundations/array.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs index 58b811bdcb04..24a796e8366a 100644 --- a/crates/typst-library/src/foundations/array.rs +++ b/crates/typst-library/src/foundations/array.rs @@ -174,9 +174,9 @@ impl Array { self.0.len() } - /// Returns the first item in the array. May be used on the left-hand side + /// Returns the first item in the array. May be used on the left-hand side of /// an assignment. Returns the default value if the array is empty or fails - /// with an error is no default value was specified. + /// with an error if no default value was specified. #[func] pub fn first( &self, @@ -189,7 +189,7 @@ impl Array { /// Returns the last item in the array. May be used on the left-hand side of /// an assignment. Returns the default value if the array is empty or fails - /// with an error is no default value was specified. + /// with an error if no default value was specified. #[func] pub fn last( &self, From 96ea20d9b7124d5bc7094b17f98c03fb5491747d Mon Sep 17 00:00:00 2001 From: Sadovoi Grigorii Date: Mon, 13 Jul 2026 15:24:57 +0300 Subject: [PATCH 009/105] Document countable elements (#8605) Co-authored-by: Laurenz --- .../src/introspection/counter.rs | 20 +++++++++++++++---- .../src/introspection/location.rs | 18 ++++++++++------- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/crates/typst-library/src/introspection/counter.rs b/crates/typst-library/src/introspection/counter.rs index 91d525d928bd..f88dd629ec5a 100644 --- a/crates/typst-library/src/introspection/counter.rs +++ b/crates/typst-library/src/introspection/counter.rs @@ -107,6 +107,15 @@ use crate::{Library, World}; /// } /// ``` /// +/// = Element counters +/// Above, there are various examples of using the @heading counter. Headings +/// are just one kind of element that can be counted. In general, counters can +/// count through any kind of @location:locatable[_locatable_ element]. +/// +/// Additionally, a counter can also count just those elements that match a +/// specific @selector. For example, `{counter(figure.where(kind: image))}` +/// counts figures containing images, but ignores other kinds of figures. +/// /// = Page counter /// The page counter is special. It is automatically stepped at each pagebreak. /// But like other counters, you can also step it manually. For example, you @@ -335,11 +344,14 @@ impl Counter { /// by manual updates, /// - If it is the @page function, counts through pages, /// - If it is a @selector[selector], counts through elements that match - /// the selector. For example, - /// - provide an element function: counts elements of that type, - /// - provide a @function.where[`where`] selector: counts a type of + /// the selector. For example, you can + /// - provide an element function to count elements of that type, + /// - provide a @function.where[`where`] selector to count a type of /// element with specific fields, - /// - provide a @label[`{

` /// and headings turn into `

` and lower (a level 1 heading thus turns into /// `

`, a level 2 heading into `

`, etc). -#[elem(Locatable, Tagged, Synthesize, Count, ShowSet, LocalName, Refable, Outlinable)] +#[elem( + since = "forever", + Locatable, + Tagged, + Synthesize, + Count, + ShowSet, + LocalName, + Refable, + Outlinable +)] pub struct HeadingElem { /// The absolute nesting depth of the heading, starting from one. If set to /// `{auto}`, it is computed from `{offset + depth}`. diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs index 5bc4387560a2..5ced41fb9429 100644 --- a/crates/typst-library/src/model/link.rs +++ b/crates/typst-library/src/model/link.rs @@ -144,7 +144,7 @@ use crate::text::{LocalName, TextElem}; /// into the built-in link handling. That said, in HTML export, depending on /// your use case, it may be possible to adjust the built-in link handling with /// a show rule on `{html.elem.where(tag: "a")}`. -#[elem(Locatable)] +#[elem(since = "forever", Locatable)] pub struct LinkElem { /// The destination the link points to. /// diff --git a/crates/typst-library/src/model/list.rs b/crates/typst-library/src/model/list.rs index 4c8784f1302a..4584949a01e4 100644 --- a/crates/typst-library/src/model/list.rs +++ b/crates/typst-library/src/model/list.rs @@ -40,7 +40,7 @@ use crate::text::TextElem; /// followed by a space to create a list item. A list item can contain multiple /// paragraphs and other block-level content. All content that is indented more /// than an item's marker becomes part of that item. -#[elem(scope, title = "Bullet List", Locatable, Tagged)] +#[elem(scope, title = "Bullet List", since = "forever", Locatable, Tagged)] pub struct ListElem { /// Defines the default @list.spacing[spacing] of the list. If it is /// `{false}`, the items are spaced apart with @@ -163,7 +163,7 @@ impl ListElem { } /// A bullet list item. -#[elem(name = "item", title = "Bullet List Item", Tagged)] +#[elem(name = "item", title = "Bullet List Item", since = "0.4.0", Tagged)] pub struct ListItem { /// The item's body. #[required] diff --git a/crates/typst-library/src/model/numbering.rs b/crates/typst-library/src/model/numbering.rs index a802a071f810..fcb54d8d195e 100644 --- a/crates/typst-library/src/model/numbering.rs +++ b/crates/typst-library/src/model/numbering.rs @@ -51,7 +51,7 @@ use crate::foundations::{Context, Func, Str, Value, cast, func}; /// = Second heading /// = Third heading /// ``` -#[func] +#[func(since = "forever")] pub fn numbering( engine: &mut Engine, context: Tracked, diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index 3d6cf972ed7d..8aceddb34a8e 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -147,7 +147,7 @@ use crate::text::{LocalName, SpaceElem, TextElem}; /// = About ACME Corp. /// == History /// ``` -#[elem(scope, keywords = ["Table of Contents", "toc"], ShowSet, LocalName, Locatable, Tagged)] +#[elem(scope, since = "forever", keywords = ["Table of Contents", "toc"], ShowSet, LocalName, Locatable, Tagged)] pub struct OutlineElem { /// The title of the outline. /// @@ -470,7 +470,14 @@ pub trait Outlinable: Refable { /// With show-set and show rules on outline entries, you can richly customize /// the outline's appearance. See the /// @outline:styling-the-outline[section on styling the outline] for details. -#[elem(scope, name = "entry", title = "Outline Entry", Locatable, Tagged)] +#[elem( + scope, + name = "entry", + title = "Outline Entry", + since = "0.6.0", + Locatable, + Tagged +)] pub struct OutlineEntry { /// The nesting level of this outline entry. Starts at `{1}` for top-level /// entries. @@ -529,7 +536,7 @@ impl OutlineEntry { /// indented, but the inner contents are simply offset from the prefix by /// the specified `gap`, rather than aligning outline-wide. For a visual /// explanation, see @outline.indent. - #[func(contextual)] + #[func(contextual, since = "0.13.0")] pub fn indented( &self, engine: &mut Engine, @@ -626,7 +633,7 @@ impl OutlineEntry { /// This also appends the element's supplement in case of figures or /// equations. For instance, it would output `1.1` for a heading, but /// `Figure 1` for a figure, as is usual for outlines. - #[func(contextual)] + #[func(contextual, since = "0.13.0")] pub fn prefix( &self, engine: &mut Engine, @@ -646,7 +653,7 @@ impl OutlineEntry { /// Creates the default inner content of the entry. /// /// This includes the body, the fill, and page number. - #[func(contextual)] + #[func(contextual, since = "0.13.0")] pub fn inner( &self, engine: &mut Engine, @@ -661,14 +668,14 @@ impl OutlineEntry { /// The content which is displayed in place of the referred element at its /// entry in the outline. For a heading, this is its @heading.body[`body`]; /// for a figure a caption and for equations, it is empty. - #[func] + #[func(since = "0.13.0")] pub fn body(&self) -> StrResult { Ok(self.outlinable()?.body()) } /// The page number of this entry's element, formatted with the numbering /// set for the referenced page. - #[func(contextual)] + #[func(contextual, since = "0.13.0")] pub fn page( &self, engine: &mut Engine, diff --git a/crates/typst-library/src/model/par.rs b/crates/typst-library/src/model/par.rs index 041c0fe09bb6..3b01299644f8 100644 --- a/crates/typst-library/src/model/par.rs +++ b/crates/typst-library/src/model/par.rs @@ -95,7 +95,7 @@ use crate::model::Numbering; /// let $a$ be the smallest of the /// three integers. Then, we ... /// ``` -#[elem(scope, title = "Paragraph", Locatable, Tagged)] +#[elem(scope, title = "Paragraph", since = "forever", Locatable, Tagged)] pub struct ParElem { /// The spacing between lines. /// @@ -722,7 +722,7 @@ impl Fold for FirstLineIndent { /// = Syntax /// Instead of calling this function, you can insert a blank line into your /// markup to create a paragraph break. -#[elem(title = "Paragraph Break", Unlabellable)] +#[elem(title = "Paragraph Break", since = "forever", Unlabellable)] pub struct ParbreakElem {} impl ParbreakElem { @@ -787,7 +787,7 @@ impl Unlabellable for Packed {} /// @par.line.number-margin[margin]. In addition, you can control whether the /// numbering is reset on each page through the /// @par.line.numbering-scope[`numbering-scope`] option. -#[elem(name = "line", title = "Paragraph Line", keywords = ["line numbering"], Construct, Locatable)] +#[elem(name = "line", title = "Paragraph Line", since = "0.12.0", keywords = ["line numbering"], Construct, Locatable)] pub struct ParLine { /// How to number each line. Accepts a /// @numbering[numbering pattern or function] taking a single number. diff --git a/crates/typst-library/src/model/quote.rs b/crates/typst-library/src/model/quote.rs index f826332f4b9d..4dd27a81d0de 100644 --- a/crates/typst-library/src/model/quote.rs +++ b/crates/typst-library/src/model/quote.rs @@ -41,7 +41,7 @@ use crate::text::{SmartQuotes, SpaceElem, TextElem}; /// flame of Udûn. Go back to the Shadow! You cannot pass. /// ] /// ``` -#[elem(Locatable, Tagged, ShowSet)] +#[elem(since = "0.9.0", Locatable, Tagged, ShowSet)] pub struct QuoteElem { /// Whether this is a block quote. /// diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index b22973922a04..0424694878a5 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -135,7 +135,7 @@ use crate::text::TextElem; /// In @beginning we prove @pythagoras. /// $ a^2 + b^2 = c^2 $ /// ``` -#[elem(title = "Reference", Locatable, Tagged, Synthesize)] +#[elem(title = "Reference", since = "forever", Locatable, Tagged, Synthesize)] pub struct RefElem { /// The target label that should be referenced. /// diff --git a/crates/typst-library/src/model/strong.rs b/crates/typst-library/src/model/strong.rs index bf06594c74b1..4e437e51d833 100644 --- a/crates/typst-library/src/model/strong.rs +++ b/crates/typst-library/src/model/strong.rs @@ -18,7 +18,7 @@ use crate::foundations::{Content, elem}; /// simply enclose it in stars/asterisks (`*`). Note that this only works at /// word boundaries. To strongly emphasize part of a word, you have to use the /// function. -#[elem(title = "Strong Emphasis", keywords = ["bold", "weight"], Locatable, Tagged)] +#[elem(title = "Strong Emphasis", since = "forever", keywords = ["bold", "weight"], Locatable, Tagged)] pub struct StrongElem { /// The delta to apply on the font weight. /// diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index b7b36f48f8db..7b12f81f242c 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -133,7 +133,7 @@ use crate::visualize::{Paint, Stroke}; /// visually, you should consider making the core information in your table /// available as text as well. You can do this by wrapping your table in a /// @figure[figure] and using its caption to summarize the table's content. -#[elem(scope, Locatable, Tagged, Synthesize, LocalName, Figurable)] +#[elem(scope, since = "forever", Locatable, Tagged, Synthesize, LocalName, Figurable)] pub struct TableElem { /// The column sizes. See the @grid:track-size[grid documentation] for more /// information on track sizing. @@ -491,7 +491,7 @@ impl TryFrom for TableItem { /// [7.34], [57], [2], /// ) /// ``` -#[elem(name = "header", title = "Table Header")] +#[elem(name = "header", title = "Table Header", since = "0.11.0")] pub struct TableHeader { /// Whether this header should be repeated across pages. #[default(true)] @@ -521,7 +521,7 @@ pub struct TableHeader { /// other information that should be visible on every page. /// /// No other table cells may be placed after the footer. -#[elem(name = "footer", title = "Table Footer")] +#[elem(name = "footer", title = "Table Footer", since = "0.11.0")] pub struct TableFooter { /// Whether this footer should be repeated across pages. #[default(true)] @@ -564,7 +564,7 @@ pub struct TableFooter { /// [19:00], [Day 1 Attendee Mixer], /// ) /// ``` -#[elem(name = "hline", title = "Table Horizontal Line")] +#[elem(name = "hline", title = "Table Horizontal Line", since = "0.11.0")] pub struct TableHLine { /// The row above which the horizontal line is placed (zero-indexed). /// Functions identically to the `y` field in @grid.hline.y[`grid.hline`]. @@ -609,7 +609,7 @@ pub struct TableHLine { /// @table.stroke[table's `stroke`] field or /// @table.cell.stroke[`table.cell`'s `stroke`] field instead if the line you /// want to place is part of all your tables' designs. -#[elem(name = "vline", title = "Table Vertical Line")] +#[elem(name = "vline", title = "Table Vertical Line", since = "0.11.0")] pub struct TableVLine { /// The column before which the vertical line is placed (zero-indexed). /// Functions identically to the `x` field in @grid.vline. @@ -729,7 +729,7 @@ pub struct TableVLine { /// [Vikram], [49], [Perseverance], /// ) /// ``` -#[elem(name = "cell", title = "Table Cell")] +#[elem(name = "cell", title = "Table Cell", since = "0.11.0")] pub struct TableCell { /// The cell's body. #[required] diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs index e82dd94460c3..2393dec0afdd 100644 --- a/crates/typst-library/src/model/terms.rs +++ b/crates/typst-library/src/model/terms.rs @@ -21,7 +21,7 @@ use crate::model::{ListItemLike, ListLike}; /// = Syntax /// This function also has dedicated syntax: Starting a line with a slash, /// followed by a term, a colon and a description creates a term list item. -#[elem(scope, title = "Term List", Locatable, Tagged)] +#[elem(scope, title = "Term List", since = "forever", Locatable, Tagged)] pub struct TermsElem { /// Defines the default @terms.spacing[spacing] of the term list. If it is /// `{false}`, the items are spaced apart with @@ -125,7 +125,7 @@ impl TermsElem { } /// A term list item. -#[elem(name = "item", title = "Term List Item", Tagged)] +#[elem(name = "item", title = "Term List Item", since = "0.4.0", Tagged)] pub struct TermItem { /// The term described by the list item. #[required] diff --git a/crates/typst-library/src/model/title.rs b/crates/typst-library/src/model/title.rs index 69fb40fd8ffe..c5739992b422 100644 --- a/crates/typst-library/src/model/title.rs +++ b/crates/typst-library/src/model/title.rs @@ -27,7 +27,7 @@ use crate::text::{FontWeight, TextElem, TextSize}; /// = Introduction /// In recent years, ... /// ``` -#[elem(Locatable, Tagged, ShowSet)] +#[elem(since = "0.14.0", Locatable, Tagged, ShowSet)] pub struct TitleElem { /// The content of the title. /// diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs index 814058242970..09c6766dff15 100644 --- a/crates/typst-library/src/pdf/accessibility.rs +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -33,7 +33,7 @@ use crate::model::{TableCell, TableElem}; /// In the future, this function may be moved out of the `pdf` module, making it /// possible to hide content in HTML export from AT. // TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`? -#[elem(Tagged)] +#[elem(since = "0.14.0", Tagged)] pub struct ArtifactElem { /// The artifact kind. /// @@ -134,7 +134,7 @@ pub enum ArtifactKind { /// caption: [The Sales org now has a new member], /// ) /// ``` -#[func] +#[func(since = "0.14.0")] pub fn table_summary( #[named] summary: Option, /// The table. @@ -200,7 +200,7 @@ pub fn table_summary( /// [30g], [45g], /// ) /// ``` -#[func] +#[func(since = "0.14.0")] pub fn header_cell( /// The nesting level of this header cell. #[named] @@ -261,7 +261,7 @@ pub fn header_cell( /// [], [Close 50 enterprise deals], [38], /// ) /// ``` -#[func] +#[func(since = "0.14.0")] pub fn data_cell( /// The table cell. /// diff --git a/crates/typst-library/src/pdf/attach.rs b/crates/typst-library/src/pdf/attach.rs index 08d74f1a3492..f49f1c267a4e 100644 --- a/crates/typst-library/src/pdf/attach.rs +++ b/crates/typst-library/src/pdf/attach.rs @@ -28,7 +28,7 @@ use crate::foundations::{Bytes, Cast, Derived, PathOrStr, elem}; /// - This element is ignored if exporting to a format other than PDF. /// - File attachments are not currently supported for PDF/A-2, even if the /// attached file conforms to PDF/A-1 or PDF/A-2. -#[elem(keywords = ["embed"], Locatable)] +#[elem(since = "0.14.0", keywords = ["embed"], Locatable)] pub struct AttachElem { /// The path of the file to be attached. /// diff --git a/crates/typst-library/src/text/case.rs b/crates/typst-library/src/text/case.rs index c6f6ec72d165..3a334e097fb8 100644 --- a/crates/typst-library/src/text/case.rs +++ b/crates/typst-library/src/text/case.rs @@ -9,7 +9,7 @@ use crate::text::TextElem; /// #lower[*My Text*] \ /// #lower[already low] /// ``` -#[func(title = "Lowercase")] +#[func(title = "Lowercase", since = "forever")] pub fn lower( /// The text to convert to lowercase. text: Caseable, @@ -25,7 +25,7 @@ pub fn lower( /// #upper[*my text*] \ /// #upper[ALREADY HIGH] /// ``` -#[func(title = "Uppercase")] +#[func(title = "Uppercase", since = "forever")] pub fn upper( /// The text to convert to uppercase. text: Caseable, diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs index de24bd547fa3..2b8743200857 100644 --- a/crates/typst-library/src/text/deco.rs +++ b/crates/typst-library/src/text/deco.rs @@ -9,7 +9,7 @@ use crate::visualize::{Color, FixedStroke, Paint, Stroke}; /// ```example /// This is #underline[important]. /// ``` -#[elem(Locatable, Tagged)] +#[elem(since = "forever", Locatable, Tagged)] pub struct UnderlineElem { /// How to @stroke[stroke] the line. /// @@ -77,7 +77,7 @@ pub struct UnderlineElem { /// ```example /// #overline[A line over text.] /// ``` -#[elem(Locatable, Tagged)] +#[elem(since = "forever", Locatable, Tagged)] pub struct OverlineElem { /// How to @stroke[stroke] the line. /// @@ -151,7 +151,7 @@ pub struct OverlineElem { /// ```example /// This is #strike[not] relevant. /// ``` -#[elem(title = "Strikethrough", Locatable, Tagged)] +#[elem(title = "Strikethrough", since = "forever", Locatable, Tagged)] pub struct StrikeElem { /// How to @stroke[stroke] the line. /// @@ -210,7 +210,7 @@ pub struct StrikeElem { /// ```example /// This is #highlight[important]. /// ``` -#[elem(Locatable, Tagged)] +#[elem(since = "0.8.0", Locatable, Tagged)] pub struct HighlightElem { /// The color to highlight the text with. /// diff --git a/crates/typst-library/src/text/linebreak.rs b/crates/typst-library/src/text/linebreak.rs index a299ef3b6df0..157af8b18d16 100644 --- a/crates/typst-library/src/text/linebreak.rs +++ b/crates/typst-library/src/text/linebreak.rs @@ -19,7 +19,7 @@ use crate::foundations::{Content, NativeElement, elem}; /// This function also has dedicated syntax: To insert a line break, simply /// write a backslash followed by whitespace. This always creates an unjustified /// break. -#[elem(title = "Line Break")] +#[elem(title = "Line Break", since = "forever")] pub struct LinebreakElem { /// Whether to justify the line before the break. /// diff --git a/crates/typst-library/src/text/lorem.rs b/crates/typst-library/src/text/lorem.rs index fc89b7c4bfb6..4ba4dd5275ce 100644 --- a/crates/typst-library/src/text/lorem.rs +++ b/crates/typst-library/src/text/lorem.rs @@ -20,7 +20,7 @@ use crate::foundations::{Str, func}; /// = More Blind Text /// #lorem(15) /// ``` -#[func(keywords = ["Blind Text"])] +#[func(since = "forever", keywords = ["Blind Text"])] pub fn lorem( /// The length of the blind text in words. words: usize, diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index 43507f68a7e3..c9ce12bf0f10 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -87,7 +87,7 @@ pub(super) fn define(global: &mut Scope) { /// With a function call. /// ]) /// ``` -#[elem(Debug, Construct, PlainText, Repr)] +#[elem(since = "forever", Debug, Construct, PlainText, Repr)] pub struct TextElem { /// A font family descriptor or priority list of font family descriptors. /// diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index f952e57aeb04..b0747bf98ca3 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -250,6 +250,7 @@ use crate::visualize::Color; #[elem( scope, title = "Raw Text / Code", + since = "forever", Synthesize, Locatable, Tagged, @@ -809,7 +810,7 @@ fn format_theme_error(error: syntect::LoadingError) -> LoadError { /// It allows you to access various properties of the line, such as the line /// number, the raw non-highlighted text, the highlighted text, and whether it /// is the first or last line of the raw block. -#[elem(name = "line", title = "Raw Text / Code Line", Tagged, PlainText)] +#[elem(name = "line", title = "Raw Text / Code Line", since = "0.9.0", Tagged, PlainText)] pub struct RawLine { /// The line number of the raw line inside of the raw block, starts at 1. #[required] diff --git a/crates/typst-library/src/text/shift.rs b/crates/typst-library/src/text/shift.rs index 08d2be5d362c..9b136c720695 100644 --- a/crates/typst-library/src/text/shift.rs +++ b/crates/typst-library/src/text/shift.rs @@ -12,7 +12,7 @@ use crate::text::{FontMetrics, ScriptMetrics, TextSize}; /// ```example /// Revenue#sub[yearly] /// ``` -#[elem(title = "Subscript", Tagged)] +#[elem(title = "Subscript", since = "forever", Tagged)] pub struct SubElem { /// Whether to use subscript glyphs from the font if available. /// @@ -71,7 +71,7 @@ pub struct SubElem { /// ```example /// 1#super[st] try! /// ``` -#[elem(title = "Superscript", Tagged)] +#[elem(title = "Superscript", since = "forever", Tagged)] pub struct SuperElem { /// Whether to use superscript glyphs from the font if available. /// diff --git a/crates/typst-library/src/text/smallcaps.rs b/crates/typst-library/src/text/smallcaps.rs index 9aaa118e134d..8284391d1e9c 100644 --- a/crates/typst-library/src/text/smallcaps.rs +++ b/crates/typst-library/src/text/smallcaps.rs @@ -40,7 +40,7 @@ use crate::foundations::{Content, elem}; /// = Introduction /// #lorem(40) /// ``` -#[elem(title = "Small Capitals")] +#[elem(title = "Small Capitals", since = "forever")] pub struct SmallcapsElem { /// Whether to turn uppercase letters into small capitals as well. /// diff --git a/crates/typst-library/src/text/smartquote.rs b/crates/typst-library/src/text/smartquote.rs index 04796b767144..f58b4632f440 100644 --- a/crates/typst-library/src/text/smartquote.rs +++ b/crates/typst-library/src/text/smartquote.rs @@ -29,7 +29,7 @@ use crate::text::{Lang, Region, TextElem}; /// = Syntax /// This function also has dedicated syntax: The normal quote characters (`'` /// and `"`). Typst automatically makes your quotes smart. -#[elem(name = "smartquote", PlainText)] +#[elem(name = "smartquote", since = "forever", PlainText)] pub struct SmartQuoteElem { /// Whether this should be a double quote. #[default(true)] diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs index 712f51012792..d413a7da8a28 100644 --- a/crates/typst-library/src/visualize/color.rs +++ b/crates/typst-library/src/visualize/color.rs @@ -277,7 +277,7 @@ static TO_SRGB: LazyLock> = LazyLock::new(|| /// ) /// })) /// ``` -#[ty(scope, cast)] +#[ty(scope, cast, since = "forever")] #[derive(Clone, PartialEq, Eq, Hash)] pub enum Color { /// A process color. @@ -337,7 +337,7 @@ impl Color { /// box(square(fill: luma(x))) /// } /// ``` - #[func] + #[func(since = "forever")] pub fn luma( args: &mut Args, /// The lightness component. @@ -390,7 +390,7 @@ impl Color { /// fill: oklab(27%, 20%, -3%, 50%) /// ) /// ``` - #[func] + #[func(since = "0.9.0")] pub fn oklab( args: &mut Args, /// The lightness component. @@ -449,7 +449,7 @@ impl Color { /// fill: oklch(40%, 0.2, 160deg, 50%) /// ) /// ``` - #[func] + #[func(since = "0.10.0")] pub fn oklch( args: &mut Args, /// The lightness component. @@ -511,7 +511,7 @@ impl Color { /// 30%, 50%, 10%, /// )) /// ``` - #[func(title = "Linear RGB")] + #[func(title = "Linear RGB", since = "0.9.0")] pub fn linear_rgb( args: &mut Args, /// The red component. @@ -569,7 +569,7 @@ impl Color { /// #square(fill: rgb(87, 127, 230)) /// #square(fill: rgb(25%, 13%, 65%)) /// ``` - #[func(title = "RGB")] + #[func(title = "RGB", since = "forever")] pub fn rgb( args: &mut Args, /// The red component. @@ -645,7 +645,7 @@ impl Color { /// fill: cmyk(27%, 0%, 3%, 5%) /// ) /// ``` - #[func(title = "CMYK")] + #[func(title = "CMYK", since = "forever")] pub fn cmyk( args: &mut Args, /// The cyan component. @@ -699,7 +699,7 @@ impl Color { /// fill: color.hsl(30deg, 50%, 60%) /// ) /// ``` - #[func(title = "HSL")] + #[func(title = "HSL", since = "0.9.0")] pub fn hsl( args: &mut Args, /// The hue angle. @@ -756,7 +756,7 @@ impl Color { /// fill: color.hsv(30deg, 50%, 60%) /// ) /// ``` - #[func(title = "HSV")] + #[func(title = "HSV", since = "0.9.0")] pub fn hsv( args: &mut Args, /// The hue angle. @@ -867,7 +867,7 @@ impl Color { /// // note that the alpha component is included by default /// #rgb(40%, 60%, 80%).components() /// ``` - #[func] + #[func(since = "0.9.0")] pub fn components( &self, /// Whether to include the alpha component. @@ -897,7 +897,7 @@ impl Color { /// #let color = cmyk(1%, 2%, 3%, 4%) /// #(color.space() == cmyk) /// ``` - #[func] + #[func(since = "0.9.0")] pub fn space(&self) -> ColorSpace { match self { Self::Process(c) => c.space().into(), @@ -908,7 +908,7 @@ impl Color { /// Returns the color's RGB(A) hex representation (such as `#ffaa32` or /// `#020304fe`). The alpha component (last two digits in `#020304fe`) is /// omitted if it is equal to `ff` (255 / 100%). - #[func] + #[func(since = "0.9.0")] pub fn to_hex(&self) -> EcoString { match self { Self::Process(c) => c.to_hex(), @@ -917,7 +917,7 @@ impl Color { } /// Lightens a color by a given factor. - #[func] + #[func(since = "forever")] pub fn lighten( &self, /// The factor to lighten the color by. @@ -930,7 +930,7 @@ impl Color { } /// Darkens a color by a given factor. - #[func] + #[func(since = "forever")] pub fn darken( &self, /// The factor to darken the color by. @@ -946,7 +946,7 @@ impl Color { /// /// Only process colors can be saturated. If you want to saturate a spot /// color, convert it into a process color first. - #[func] + #[func(since = "0.9.0")] pub fn saturate( &self, span: Span, @@ -967,7 +967,7 @@ impl Color { /// /// Only process colors can be desaturated. If you want to desaturate a spot /// color, convert it into a process color first. - #[func] + #[func(since = "0.9.0")] pub fn desaturate( &self, span: Span, @@ -992,7 +992,7 @@ impl Color { /// #square(fill: yellow.negate()) /// #square(fill: yellow.negate(space: rgb)) /// ``` - #[func] + #[func(since = "forever")] pub fn negate( &self, /// The color space used for the transformation. By default, a @@ -1017,7 +1017,7 @@ impl Color { /// /// This function only works on color models with a well-defined hue /// component, i.e. Oklch, HSL, and HSV. - #[func] + #[func(since = "0.9.0")] pub fn rotate( &self, span: Span, @@ -1068,7 +1068,7 @@ impl Color { /// #block(fill: color.mix(red, blue, white)) /// #block(fill: color.mix((red, 70%), (blue, 30%))) /// ``` - #[func] + #[func(since = "0.7.0")] pub fn mix( /// The colors, optionally with weights, specified as a pair (array of /// length two) of color and weight (float or ratio). @@ -1100,7 +1100,7 @@ impl Color { /// #block(fill: red.transparentize(50%))[half red] /// #block(fill: red.transparentize(75%))[quarter red] /// ``` - #[func] + #[func(since = "0.11.0")] pub fn transparentize( &self, /// The factor to change the alpha value by. @@ -1121,7 +1121,7 @@ impl Color { /// #block(fill: half-red.opacify(50%))[three quarters red] /// #block(fill: half-red.opacify(-50%))[one quarter red] /// ``` - #[func] + #[func(since = "0.11.0")] pub fn opacify( &self, /// The scale to change the alpha value by. @@ -2300,7 +2300,7 @@ impl Repr for SpotColor { /// environment. Once you have created a spot colorant, you can create /// colors using its @color.spot.tint[`tint` method]. #[derive(Debug, Clone, Eq, PartialEq, Hash)] -#[ty(scope, name = "spot", title = "Spot Colorant")] +#[ty(scope, name = "spot", title = "Spot Colorant", since = "0.15.0")] pub struct SpotColorant { /// Name of the spot colorant to use, if any. pub name: Option, @@ -2317,7 +2317,7 @@ impl Repr for SpotColorant { #[scope] impl SpotColorant { /// Create a new spot colorant. - #[func(constructor)] + #[func(constructor, since = "0.15.0")] pub fn construct( /// Name of the spot colorant to use. /// @@ -2371,7 +2371,7 @@ impl SpotColorant { /// #square(fill: pantone.tint(70%)) /// #square(fill: pantone.tint(40%)) /// ``` - #[func] + #[func(since = "0.15.0")] pub fn tint( &self, /// The tint percentage, between `{0%}` and `{100%}`. diff --git a/crates/typst-library/src/visualize/curve.rs b/crates/typst-library/src/visualize/curve.rs index b75dc6f11562..b87cbbe5cb44 100644 --- a/crates/typst-library/src/visualize/curve.rs +++ b/crates/typst-library/src/visualize/curve.rs @@ -39,7 +39,7 @@ use super::FixedStroke; /// curve.close(), /// ) /// ``` -#[elem(scope)] +#[elem(scope, since = "0.13.0")] pub struct CurveElem { /// How to fill the curve. /// @@ -170,7 +170,7 @@ impl TryFrom for CurveComponent { /// curve.close(), /// ) /// ``` -#[elem(name = "move", title = "Curve Move")] +#[elem(name = "move", title = "Curve Move", since = "0.13.0")] pub struct CurveMove { /// The starting point for the new component. #[required] @@ -193,7 +193,7 @@ pub struct CurveMove { /// curve.line((150pt, 0pt)), /// ) /// ``` -#[elem(name = "line", title = "Curve Line")] +#[elem(name = "line", title = "Curve Line", since = "0.13.0")] pub struct CurveLine { /// The point at which the line shall end. #[required] @@ -233,7 +233,7 @@ pub struct CurveLine { /// curve.quad((20pt, 20pt), (100pt, 0pt)), /// ) /// ``` -#[elem(name = "quad", title = "Curve Quadratic Segment")] +#[elem(name = "quad", title = "Curve Quadratic Segment", since = "0.13.0")] pub struct CurveQuad { /// The control point of the quadratic Bézier curve. /// @@ -280,7 +280,7 @@ pub struct CurveQuad { /// curve.cubic((10pt, 20pt), (90pt, 60pt), (100pt, 0pt)), /// ) /// ``` -#[elem(name = "cubic", title = "Curve Cubic Segment")] +#[elem(name = "cubic", title = "Curve Cubic Segment", since = "0.13.0")] pub struct CurveCubic { /// The control point going out from the start of the curve segment. /// @@ -362,7 +362,7 @@ pub struct CurveCubic { /// #shape(mode: "smooth") /// #shape(mode: "straight") /// ``` -#[elem(name = "close", title = "Curve Close")] +#[elem(name = "close", title = "Curve Close", since = "0.13.0")] pub struct CurveClose { /// How to close the curve. pub mode: CloseMode, diff --git a/crates/typst-library/src/visualize/gradient.rs b/crates/typst-library/src/visualize/gradient.rs index f5b9eb7f8c3d..e9de9dfa62a2 100644 --- a/crates/typst-library/src/visualize/gradient.rs +++ b/crates/typst-library/src/visualize/gradient.rs @@ -186,7 +186,7 @@ use crate::visualize::{ /// @color.linear-rgb color spaces. This avoids needing to encode these color /// spaces in your PDF file, but it does add extra stops to your gradient, which /// can increase the file size. -#[ty(scope, cast)] +#[ty(scope, cast, since = "0.9.0")] #[derive(Clone, Eq, PartialEq, Hash)] pub enum Gradient { Linear(Arc), @@ -209,7 +209,7 @@ impl Gradient { /// ), /// ) /// ``` - #[func(title = "Linear Gradient")] + #[func(title = "Linear Gradient", since = "0.9.0")] pub fn linear( args: &mut Args, span: Span, @@ -299,7 +299,7 @@ impl Gradient { /// )), /// ) /// ``` - #[func(title = "Radial Gradient")] + #[func(title = "Radial Gradient", since = "0.9.0")] fn radial( span: Span, /// The color @gradient:stops[stops] of the gradient. @@ -416,7 +416,7 @@ impl Gradient { /// )), /// ) /// ``` - #[func(title = "Conic Gradient")] + #[func(title = "Conic Gradient", since = "0.9.0")] pub fn conic( span: Span, /// The color @gradient:stops[stops] of the gradient. @@ -480,7 +480,7 @@ impl Gradient { /// #rect(fill: grad.sharp(5)) /// #rect(fill: grad.sharp(5, smoothness: 20%)) /// ``` - #[func] + #[func(since = "0.9.0")] pub fn sharp( &self, /// The number of stops in the gradient. @@ -575,7 +575,7 @@ impl Gradient { /// .repeat(4), /// ) /// ``` - #[func] + #[func(since = "0.9.0")] pub fn repeat( &self, /// The number of times to repeat the gradient. @@ -655,7 +655,7 @@ impl Gradient { } /// Returns the kind of this gradient. - #[func] + #[func(since = "0.9.0")] pub fn kind(&self) -> Func { match self { Self::Linear(_) => Self::linear_data().into(), @@ -665,7 +665,7 @@ impl Gradient { } /// Returns the stops of this gradient. - #[func] + #[func(since = "0.9.0")] pub fn stops(&self) -> Vec { match self { Self::Linear(linear) => linear @@ -696,7 +696,7 @@ impl Gradient { } /// Returns the mixing space of this gradient. - #[func] + #[func(since = "0.9.0")] pub fn space(&self) -> ColorSpace { match self { Self::Linear(linear) => linear.space.clone(), @@ -706,7 +706,7 @@ impl Gradient { } /// Returns the relative placement of this gradient. - #[func] + #[func(since = "0.9.0")] pub fn relative(&self) -> Smart { match self { Self::Linear(linear) => linear.relative, @@ -718,7 +718,7 @@ impl Gradient { /// Returns the angle of this gradient. /// /// Returns `{none}` if the gradient is neither linear nor conic. - #[func] + #[func(since = "0.9.0")] pub fn angle(&self) -> Option { match self { Self::Linear(linear) => Some(linear.angle), @@ -730,7 +730,7 @@ impl Gradient { /// Returns the center of this gradient. /// /// Returns `{none}` if the gradient is neither radial nor conic. - #[func] + #[func(since = "0.13.0")] pub fn center(&self) -> Option> { match self { Self::Linear(_) => None, @@ -742,7 +742,7 @@ impl Gradient { /// Returns the radius of this gradient. /// /// Returns `{none}` if the gradient is not radial. - #[func] + #[func(since = "0.13.0")] pub fn radius(&self) -> Option { match self { Self::Linear(_) => None, @@ -754,7 +754,7 @@ impl Gradient { /// Returns the focal-center of this gradient. /// /// Returns `{none}` if the gradient is not radial. - #[func] + #[func(since = "0.13.0")] pub fn focal_center(&self) -> Option> { match self { Self::Linear(_) => None, @@ -766,7 +766,7 @@ impl Gradient { /// Returns the focal-radius of this gradient. /// /// Returns `{none}` if the gradient is not radial. - #[func] + #[func(since = "0.13.0")] pub fn focal_radius(&self) -> Option { match self { Self::Linear(_) => None, @@ -780,7 +780,7 @@ impl Gradient { /// The position is either a position along the gradient (a @ratio[ratio] /// between `{0%}` and `{100%}`) or an @angle[angle]. Any value outside of /// this range will be clamped. - #[func] + #[func(since = "0.9.0")] pub fn sample( &self, /// The position at which to sample the gradient. @@ -801,7 +801,7 @@ impl Gradient { /// Samples the gradient at multiple positions at once and returns the /// results as an array. - #[func] + #[func(since = "0.9.0")] pub fn samples( &self, /// The positions at which to sample the gradient. diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index b703bb70c847..d7aff5582da8 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -48,7 +48,7 @@ use crate::visualize::image::pdf::PdfDocument; /// ], /// ) /// ``` -#[elem(Locatable, Tagged, Synthesize, LocalName, Figurable)] +#[elem(since = "forever", Locatable, Tagged, Synthesize, LocalName, Figurable)] pub struct ImageElem { /// A path to an image file or raw bytes making up an image in one of the /// supported @image.format[formats]. diff --git a/crates/typst-library/src/visualize/line.rs b/crates/typst-library/src/visualize/line.rs index 113c105a82f7..1b0e7d66bed0 100644 --- a/crates/typst-library/src/visualize/line.rs +++ b/crates/typst-library/src/visualize/line.rs @@ -15,7 +15,7 @@ use crate::visualize::Stroke; /// stroke: 2pt + maroon, /// ) /// ``` -#[elem] +#[elem(since = "forever")] pub struct LineElem { /// The start point of the line. /// diff --git a/crates/typst-library/src/visualize/polygon.rs b/crates/typst-library/src/visualize/polygon.rs index de06871c1076..0557b6afd034 100644 --- a/crates/typst-library/src/visualize/polygon.rs +++ b/crates/typst-library/src/visualize/polygon.rs @@ -21,7 +21,7 @@ use crate::visualize::{FillRule, Paint, Stroke}; /// (0%, 2cm), /// ) /// ``` -#[elem(scope)] +#[elem(scope, since = "0.1.0")] pub struct PolygonElem { /// How to fill the polygon. /// @@ -60,7 +60,7 @@ impl PolygonElem { /// vertices: 3, /// ) /// ``` - #[func(title = "Regular Polygon")] + #[func(title = "Regular Polygon", since = "0.8.0")] pub fn regular( span: Span, diff --git a/crates/typst-library/src/visualize/shape.rs b/crates/typst-library/src/visualize/shape.rs index b52aff00b30c..07fb86790f60 100644 --- a/crates/typst-library/src/visualize/shape.rs +++ b/crates/typst-library/src/visualize/shape.rs @@ -16,7 +16,7 @@ use kurbo::{PathEl, Shape as _}; /// to fit the content. /// ] /// ``` -#[elem(title = "Rectangle")] +#[elem(title = "Rectangle", since = "forever")] pub struct RectElem { /// The rectangle's width, relative to its parent container. pub width: Smart>, @@ -140,7 +140,7 @@ pub struct RectElem { /// sized to fit. /// ] /// ``` -#[elem] +#[elem(since = "forever")] pub struct SquareElem { /// The square's side length. This is mutually exclusive with `width` and /// `height`. @@ -218,7 +218,7 @@ pub struct SquareElem { /// to fit the content. /// ] /// ``` -#[elem] +#[elem(since = "forever")] pub struct EllipseElem { /// The ellipse's width, relative to its parent container. pub width: Smart>, @@ -268,7 +268,7 @@ pub struct EllipseElem { /// sized to fit. /// ] /// ``` -#[elem] +#[elem(since = "forever")] pub struct CircleElem { /// The circle's radius. This is mutually exclusive with `width` and /// `height`. diff --git a/crates/typst-library/src/visualize/stroke.rs b/crates/typst-library/src/visualize/stroke.rs index 3851d53cf812..33e63b7b7676 100644 --- a/crates/typst-library/src/visualize/stroke.rs +++ b/crates/typst-library/src/visualize/stroke.rs @@ -49,7 +49,7 @@ use crate::visualize::{Color, Gradient, Paint, Tiling}; /// constructor function. For example, `{(2pt + blue).thickness}` is `{2pt}`. /// Meanwhile, `{stroke(red).cap}` is `{auto}` because it's unspecified. Fields /// set to `{auto}` are inherited. -#[ty(scope, cast)] +#[ty(scope, cast, since = "forever")] #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] pub struct Stroke { /// The stroke's paint. @@ -96,7 +96,7 @@ impl Stroke { /// #my-func(red) \ /// #my-func(stroke(cap: "round", thickness: 1pt)) /// ``` - #[func(constructor)] + #[func(constructor, since = "forever")] pub fn construct( args: &mut Args, diff --git a/crates/typst-library/src/visualize/tiling.rs b/crates/typst-library/src/visualize/tiling.rs index 9ae8494ed9a5..9b941531d498 100644 --- a/crates/typst-library/src/visualize/tiling.rs +++ b/crates/typst-library/src/visualize/tiling.rs @@ -53,7 +53,7 @@ use crate::visualize::RelativeTo; /// #set text(fill: pat) /// #lorem(10) /// ``` -#[ty(scope, cast, keywords = ["pattern"])] +#[ty(scope, cast, since = "0.13.0", keywords = ["pattern"])] #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct Tiling(Arc); @@ -92,7 +92,7 @@ impl Tiling { /// /// #rect(width: 100%, height: 60pt, fill: pat) /// ``` - #[func(constructor)] + #[func(constructor, since = "0.13.0")] pub fn construct( engine: &mut Engine, span: Span, From a44d3c1eed788178caa85cdedab17cd50660fb2b Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Mon, 1 Jun 2026 13:57:53 +0200 Subject: [PATCH 017/105] Ensure every documented item has a `since` --- docs/components/category.typ | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/components/category.typ b/docs/components/category.typ index 4057f6097eea..80ace69ce6a8 100644 --- a/docs/components/category.typ +++ b/docs/components/category.typ @@ -285,6 +285,8 @@ set text(0.75em) gap small[Since: #info.since] + } else { + panic("missing `since` for function " + info.def-site.key + " (" + stdx.str-from-path(info.def-site.path) + ")") } gap deprecation(deprecation-info) @@ -298,6 +300,8 @@ set text(0.75em) gap small[Since: #ty-info.since] + } else { + panic("missing `since` for type " + ty-info.def-site.key + " (" + stdx.str-from-path(ty-info.def-site.path) + ")") } deprecation(deprecation-info) sources-link(ty-info) From d7c6e5960a159b2e4e5140686ea34c6be74c82a3 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Wed, 15 Jul 2026 20:40:25 +0100 Subject: [PATCH 018/105] Add unreleased warning to documentation (#8341) Co-authored-by: Martin Haug Co-authored-by: Laurenz --- .github/workflows/release.yml | 2 +- docs/assets/docs.css | 15 +++++++++++++++ docs/components/base.typ | 10 ++++++++++ docs/components/section.typ | 7 +++++-- docs/components/styling.typ | 11 +++++++++-- docs/components/system.typ | 1 + docs/src/args.rs | 3 +++ docs/src/main.rs | 3 +++ docs/src/world.rs | 12 +++++++----- 9 files changed, 54 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd3dc3e298d3..cb27e12e3bb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,7 +102,7 @@ jobs: uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Compile documentation - run: cargo docit compile --format pdf + run: cargo docit compile --format pdf --release - name: Rename documentation file run: mv docs/dist/docs.pdf typst-documentation.pdf diff --git a/docs/assets/docs.css b/docs/assets/docs.css index 640145cc5a6b..ddb7450c20d9 100644 --- a/docs/assets/docs.css +++ b/docs/assets/docs.css @@ -745,6 +745,21 @@ a.pill { ); } +.dev-version-warning { + position: absolute; + right: 8px; + top: 8px; + padding-block: 4px; + padding-inline: 8px; + border-radius: 8px; + width: unset; + background: #565565; + color: #fdfdfd; + font-size: 12px; + font-weight: bold; + text-transform: uppercase; +} + /* Navigation breadcrumbs */ .breadcrumbs { diff --git a/docs/components/base.typ b/docs/components/base.typ index 8015e0404c0d..8f3b694e444a 100644 --- a/docs/components/base.typ +++ b/docs/components/base.typ @@ -324,3 +324,13 @@ #let insertion(name, fallback: none) = context { stdx.config.insertions.at(name, default: fallback) } + +// Displays an indicator for a development build. +#let dev-version-warning() = { + let body = [Development build #stdx.commit] + context if target() == "html" { + html.div(class: "dev-version-warning", body) + } else { + small(body) + } +} diff --git a/docs/components/section.typ b/docs/components/section.typ index b907a2b8b373..58463d125743 100644 --- a/docs/components/section.typ +++ b/docs/components/section.typ @@ -1,6 +1,6 @@ #import "base.typ": ( - classnames, heading-offset, icon, labelled, short-or-long, title-state, - tooltip-counter, + classnames, dev-version-warning, heading-offset, icon, labelled, + short-or-long, title-state, tooltip-counter, ) #import "linking.typ": register-def #import "nav.typ": nav-breadcrumbs, nav-folding, nav-on-this-page, nav-prev-next @@ -123,6 +123,9 @@ }) html.body(class: classnames("docs", class), { + if stdx.is-dev-version { + dev-version-warning() + } html.header(class: "w695", { html.button( class: "hamburger", diff --git a/docs/components/styling.typ b/docs/components/styling.typ index 65c557503cec..b20b4884b079 100644 --- a/docs/components/styling.typ +++ b/docs/components/styling.typ @@ -4,7 +4,9 @@ // element docs (as opposed to componentized non-prose content). #import "system.typ": colors, fonts, sizes -#import "base.typ": labelled, small, title-state, with-short-versions +#import "base.typ": ( + dev-version-warning, labelled, small, title-state, with-short-versions, +) #import "example.typ": example, preview, source #import "footnote.typ": footnote-rule, with-footnotes #import "linking.typ": def-label, def-metadata, register-def @@ -68,11 +70,16 @@ set page( numbering: "1", header: { + set align(right) counter(footnote).update(()) + if stdx.is-dev-version { + dev-version-warning() + } + h(1fr) context { let title = title-state.get() if title != none { - align(right, small(title)) + small(title) } } }, diff --git a/docs/components/system.typ b/docs/components/system.typ index 728d09e80e9c..f338045c7e05 100644 --- a/docs/components/system.typ +++ b/docs/components/system.typ @@ -17,6 +17,7 @@ ), red: ( shade-10: rgb("ffcbc4"), + shade-50: rgb("ea3e47"), ), green: ( shade-10: rgb("d1ffe2"), diff --git a/docs/src/args.rs b/docs/src/args.rs index afc3a54b2e50..ca13d2896b4e 100644 --- a/docs/src/args.rs +++ b/docs/src/args.rs @@ -74,6 +74,9 @@ pub struct CompileArgs { /// The output format. #[arg(long = "format", short = 'f', default_value_t)] pub format: OutputFormat, + /// Do not add the "development version" warning to the generated PDF. + #[arg(long)] + pub release: bool, /// Produces performance timings of the compilation process. #[arg(long = "timings", value_name = "OUTPUT_JSON")] pub timings: Option, diff --git a/docs/src/main.rs b/docs/src/main.rs index e3100d664534..164e500f2e27 100644 --- a/docs/src/main.rs +++ b/docs/src/main.rs @@ -114,6 +114,8 @@ struct Config { output: Option, /// The kind of output to produce. output_format: OutputFormat, + /// Whether to include a "development version" warning in the documentation. + is_dev_version: bool, /// A live reload server for the `watch` subcommand. server: Option, /// Whether to open the output after compilation. @@ -131,6 +133,7 @@ impl Config { OutputFormat::Website => Some(SITE_PATH.into()), }), output_format: args.format, + is_dev_version: !args.release, server: (serve && args.format == OutputFormat::Website) .then(|| HttpServer::new("docs", None, true).unwrap()), open: args.open, diff --git a/docs/src/world.rs b/docs/src/world.rs index 954cf2b9bb24..88e345fe3a1a 100644 --- a/docs/src/world.rs +++ b/docs/src/world.rs @@ -25,7 +25,7 @@ use typst_html::{HtmlAttrs, HtmlElem, attr, tag}; use typst_kit::datetime::Time; use typst_kit::diagnostics::DiagnosticWorld; use typst_kit::files::{FileLoader, FileStore, FsRoot}; -use typst_utils::{LazyHash, PicoStr}; +use typst_utils::{LazyHash, PicoStr, display_commit}; use crate::Config; use crate::example::FRAME_RULE; @@ -67,7 +67,7 @@ impl DocWorld { /// and entrypoint file. pub fn new(config: &Config) -> Self { Self { - library: LazyHash::new(library()), + library: LazyHash::new(library(config.is_dev_version)), files: FileStore::new(DocsFiles::new(config.input.as_deref())), now: Time::system(), } @@ -198,10 +198,10 @@ pub static FONTS: LazyLock<(LazyHash, Vec)> = LazyLock::new(|| { /// A standard library that is extended for docs compilation. Includes /// - an `stdx` module with various utilities /// - a few patched show rules -fn library() -> Library { +fn library(is_dev_version: bool) -> Library { let mut lib = Library::builder().with_features(Features::all()).build(); let scope = lib.global.scope_mut(); - scope.define("stdx", stdx_module()); + scope.define("stdx", stdx_module(is_dev_version)); lib.rules.replace(Target::Html, PATCHED_LINK_RULE); lib.rules.replace(Target::Html, PATCHED_IMAGE_RULE); lib.rules.register(Target::Paged, FRAME_RULE); @@ -209,7 +209,7 @@ fn library() -> Library { } /// A module with various utilities for the docs. -fn stdx_module() -> Module { +fn stdx_module(is_dev_version: bool) -> Module { let mut scope = Scope::new(); scope.define_elem::(); scope.define_func::(); @@ -227,6 +227,8 @@ fn stdx_module() -> Module { scope.define_func::(); scope.define("commit", typst_utils::version().commit()); scope.define("shorthands", crate::reflect::shorthands()); + scope.define("commit", display_commit(typst_utils::version().commit())); + scope.define("is-dev-version", is_dev_version); Module::new("stdx", scope) } From 14fa8cd16446e2be59a44468a649ca3d315766f1 Mon Sep 17 00:00:00 2001 From: Ian Date: Wed, 15 Jul 2026 15:42:44 -0400 Subject: [PATCH 019/105] Replace `sort_unstable` with `sort` to revert small performance regression (#8644) --- crates/typst-library/src/text/font/info.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/text/font/info.rs b/crates/typst-library/src/text/font/info.rs index 4239d3b13bbf..c0f32ab59133 100644 --- a/crates/typst-library/src/text/font/info.rs +++ b/crates/typst-library/src/text/font/info.rs @@ -289,7 +289,12 @@ pub struct Coverage(Vec); impl Coverage { /// Encode a vector of codepoints. pub fn from_vec(mut codepoints: Vec) -> Self { - codepoints.sort_unstable(); + #[expect( + clippy::stable_sort_primitive, + reason = "`sort_unstable` hurts performance here. Compiling a 'Hello World!' \ + PDF with `--ignore-system-fonts` goes from 7.8ms to 9.1ms or +16%." + )] + codepoints.sort(); codepoints.dedup(); let mut runs = Vec::new(); From 62ec6eb0a0c7ae07b10afc758d2391041da92eb3 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 16 Jul 2026 18:37:11 +0200 Subject: [PATCH 020/105] Fix gaps appearing in multi-page lists with `number-align` / `marker-align` (#8649) --- crates/typst-layout/src/lists.rs | 9 +++++++-- tests/ref/pdf/hashes.txt | 1 + tests/ref/render/issue-8648-enum-gap.png | Bin 0 -> 974 bytes tests/ref/svg/hashes.txt | 1 + tests/suite/model/list.typ | 8 ++++++++ 5 files changed, 17 insertions(+), 2 deletions(-) create mode 100644 tests/ref/render/issue-8648-enum-gap.png diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs index 47656fad1667..e36da1d0ff09 100644 --- a/crates/typst-layout/src/lists.rs +++ b/crates/typst-layout/src/lists.rs @@ -588,8 +588,13 @@ impl<'a> ItemLayouter<'a> { let mut frames = vec![]; for (i, body_frame) in body_fragment.into_iter().enumerate() { let width = self.body_offset.x + body_frame.width(); - let height = (marker.height() + self.marker_offset.y) - .max(body_frame.height() + self.body_offset.y); + + let mut height = body_frame.height() + self.body_offset.y; + if i == self.first_frame { + // Also consider the marker height, but only for the frame into + // which it will be placed. + height.set_max(marker.height() + self.marker_offset.y); + } let mut frame = Frame::soft(Size::new(width, height)); diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index 77ee3e2850de..b218d1b78d47 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -847,6 +847,7 @@ ce0a8c8453f682afc61280473b32b856 issue-8261-string-as-non-numeric a06b2482f1d0f5ca88d5442e9c2d1d4f issue-8383-headers-pdf-ua d1f419b4e576bb3ae9c4b2ed45de67c0 issue-852-mat-type d6fad9e9256e5df7dd2d7f631ad16e40 issue-8520-script-stylistic-set +f438c7e72e95fc4b812c573b9ff9494d issue-8648-enum-gap 6e13c8a43b0a4afd9a41a2d503ae5d5d issue-886-args-sink aa87ad6ade20c3470dd44d260dfa6811 issue-color-mix-luma 5b94002ca5168ccda6d3200861bb81af issue-columns-heading diff --git a/tests/ref/render/issue-8648-enum-gap.png b/tests/ref/render/issue-8648-enum-gap.png new file mode 100644 index 0000000000000000000000000000000000000000..237b686a0ca6d4ea6565804237679e892750dfd7 GIT binary patch literal 974 zcmeAS@N?(olHy`uVBq!ia0vp^6$}iFx*R|T!!zsO{}>pUr+T_LhE&{od*`NqNV?3i z!v7Ptaduwwx^?3cOSIP#-!02Zb}RJG$jn}pdrPY7;-pKnvc%W23d?AhFiEi;UT`qB z;NT7h$HEI42?d>}9bz0DA2TiDD^B?FO0;`J>f-_cSF^yAlpI zu`XxHQrnL$p70}Ov|t9 zM5SGpcvdEPAUyq`x;W1R(|r$?@qsjGHtJ7ftGkiR@cn7O;iel5dNw?Fj2oWMHLf~- z|GMPCw=?+{-eh=oWxl9%yNN;*tG%|(t(gilm!5mjI5k$@aN@L^bJsn{7O%X_%y8g- z$P2DE;nNHZJK}6-O)OxIk2>q(bxOCozd|Bzix|5oqKMpedt-hKl|8Y>)r%|#`2|KZy$L3Hc)pu zD8{+PbRsr@7`G3HU9<*rK0Il({(IH_Up7PhY3~F2Yz3aZ7Z?BA!P^(&a_6x3>BK)z z1;2loIx{`7>W=N1-cr%mR-g!3&Y*WL^JzfX&gvcSV-;#|yqLdtW+8J$l!8Eo=07IW zeX}_CSiO*o`<$xXE*6sIz`|YU_QP+oyU~5CX6bA{y(A^}BL5vt@B6N?~?uAf@^AzS2t_Ag7n z!!md0C`1%+J=l7`brqZL1MQn8&!q1v2fR1mW%=-L)Z$~0H&^jIkbZwqd^yj9(Dy9a j+p~f0fO(%dM$QEF;6J(Zj#~Ad0%cTBS3j3^P6S literal 0 HcmV?d00001 diff --git a/tests/ref/svg/hashes.txt b/tests/ref/svg/hashes.txt index 077e2bdbecb0..bedd3b4f008a 100644 --- a/tests/ref/svg/hashes.txt +++ b/tests/ref/svg/hashes.txt @@ -844,6 +844,7 @@ e757b359586c26a3453ab45df86c1591 issue-8261-string-as-non-numeric 49c58d52a09c8a0e2941f0c9b3e02a26 issue-8372-stretched-glyphs c09d6d3c63dcd6a04c2daea43e75e0cb issue-852-mat-type d2c75477a39f501e64b586e9154160c4 issue-8520-script-stylistic-set +32f5c5d2ec1fa8f20c77ac7552e25131 issue-8648-enum-gap da43cee0ff9ba707d843f89147cd6be0 issue-886-args-sink 9693e8d3687688b8ed78ae16ae815352 issue-color-mix-luma 4c01dfc0d145e59abdb4413234dfd836 issue-columns-heading diff --git a/tests/suite/model/list.typ b/tests/suite/model/list.typ index 3e1b588bf254..ddde8758a242 100644 --- a/tests/suite/model/list.typ +++ b/tests/suite/model/list.typ @@ -623,3 +623,11 @@ d - #text(48pt)[E] - #block(inset: 10pt, stroke: red)[Hello world!] - #rect[Hello world!] + +--- issue-8648-enum-gap paged --- +#set page(height: 150pt) +#set enum(number-align: top) + ++ #lines(3) ++ #lines(8) ++ #lines(2) From ed1973b0aec242a41cd3331b266ca13fb887fd59 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 16 Jul 2026 18:37:36 +0200 Subject: [PATCH 021/105] Select common mime types by extension in bundle watch server (#8650) --- crates/typst-kit/src/server.rs | 91 ++++++++++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 10 deletions(-) diff --git a/crates/typst-kit/src/server.rs b/crates/typst-kit/src/server.rs index 1d4ee95d8306..7da56cfe18fe 100644 --- a/crates/typst-kit/src/server.rs +++ b/crates/typst-kit/src/server.rs @@ -225,17 +225,88 @@ fn inject_live_reload_script(html: &mut String) { html.insert_str(pos, LIVE_RELOAD_SCRIPT); } -/// Selects a MIME type for a request based on path and data. +/// Selects a MIME type for a request based on file extension and/or data. +/// +/// First, tries to find a match for the file extension and if that yields +/// nothing, inspects the data itself. fn select_mime_type(path: &str, buf: &[u8]) -> Option<&'static str> { - match path.rsplit_once('.').map(|(_, r)| r) { - Some("html") => Some("text/html"), - Some("pdf") => Some("application/pdf"), - Some("png") => Some("image/png"), - Some("svg") => Some("image/svg+xml"), - Some("css") => Some("text/css"), - Some("js") => Some("text/javascript"), - _ => infer::get(buf).map(|ty| ty.mime_type()), - } + path.rsplit_once('.') + .and_then(|(_, r)| select_mime_type_by_extension(r)) + .or_else(|| infer::get(buf).map(|ty| ty.mime_type())) +} + +/// Selects a MIME type for a request based on the file extension. +/// +/// See for the +/// full list of standardized MIME types. This function only handles a subset of +/// this list. +/// +/// Additionally, it handles a few very commonly used unstandardized types. +fn select_mime_type_by_extension(ext: &str) -> Option<&'static str> { + Some(match ext.to_lowercase().as_str() { + // Web. + "html" | "htm" => "text/html", + "xhtml" => "application/xhtml+xml", + "css" => "text/css", + "js" | "mjs" => "text/javascript", + "wasm" => "application/wasm", + + // Document. + "typ" => "text/vnd.typst", + "txt" => "text/plain", + "pdf" => "application/pdf", + "md" => "text/markdown", + + // Font. + "ttc" | "otc" => "font/collection", + "ttf" => "font/ttf", + "otf" => "font/otf", + "woff" => "font/woff", + "woff2" => "font/woff2", + + // Container. + "zip" => "application/zip", + // Not standardized by IANA. + "tar" => "application/x-tar", + + // Data. + // This file extension is not specified, but commonly used. + "bin" => "application/octet-stream", + "csv" => "text/csv", + // RFC 7303, § 4.1 recommends `application/` over `text/`. + "xml" => "application/xml", + "json" => "application/json", + "yaml" | "yml" => "application/yaml", + + // Image. + "avif" => "image/avif", + "bmp" => "image/bmp", + "gif" => "image/gif", + "ico" => "image/vnd.microsoft.icon", + "jpg" | "jpeg" => "image/jpeg", + "jxl" => "image/jxl", + "png" => "image/png", + "svg" => "image/svg+xml", + "tif" | "tiff" => "image/tiff", + "webp" => "image/webp", + + // Audio. + "mp3" => "audio/mpeg", + "ogg" | "oga" | "spx" | "opus" => "audio/ogg", + // Not standardized by IANA and there are other common variations (e.g. + // `audio/x-wav` and `audio/vnd.wave`), but this one seems most commonly + // in use. + "wav" => "audio/wav", + + // Video. + "mp4" => "video/mp4", + "mpeg" => "video/mpeg", + "ogv" => "video/ogg", + // Not standardized by IANA, but this type is practically in use. + "webm" => "video/webm", + + _ => return None, + }) } /// Holds data and notifies consumers when it's updated. From edb6569a8245c2b0817282a19e5399f4dbdbb10c Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 17 Jul 2026 10:08:40 +0200 Subject: [PATCH 022/105] Typst 0.15.1 changelog (#8651) --- docs/content/changelog/0.15.1.typ | 25 +++++++++++++++++++++++++ docs/content/changelog/index.typ | 1 + 2 files changed, 26 insertions(+) create mode 100644 docs/content/changelog/0.15.1.typ diff --git a/docs/content/changelog/0.15.1.typ b/docs/content/changelog/0.15.1.typ new file mode 100644 index 000000000000..097a6d25699c --- /dev/null +++ b/docs/content/changelog/0.15.1.typ @@ -0,0 +1,25 @@ +#import "utils.typ": * + += Library + +== Text +- Updated New Computer Modern fonts to version 8.1.1, fixing a bug where the regular weight of the math font was still using the old @math.cal[calligraphic letterforms] that were supposed to live in @text.stylistic-set[stylistic set] 6 #pr(8552) + +== Math +- Fixed a regression where alignment points did not work correctly when placed within @math.lr[`lr`] / matched delimiters #pr(8566) +- Fixed a regression where @math.op[`op`] elements could be vertically misaligned #pr(8546) + +== Layout +- Fixed a bug where gaps could appear in multi-page lists with @enum.number-align[`number-align`] / @list.marker-align[`marker-align`] set to an alignment with a vertical component #pr(8649) + += Export + +== SVG +- Fixed that inline SVGs resulting from @html.frame elements did not respect pretty printing #pr(8535) + +== Bundle +- In the watch server, the automatic selection of appropriate `Content-Type` headers based on file extensions was expanded to include more commonly used file types (e.g. `json` files) #pr(8650) +- The error for PNG/SVG documents with multiple pages is now only raised if it persists until the final document iteration #pr(8618) + += Command Line Interface +- The `typst eval` subcommand will now exit with code 1 when the provided expression fails to evaluate #pr(8623) diff --git a/docs/content/changelog/index.typ b/docs/content/changelog/index.typ index 2c83504a9ac8..f9e644959f1d 100644 --- a/docs/content/changelog/index.typ +++ b/docs/content/changelog/index.typ @@ -24,6 +24,7 @@ "0.14.1": "03.12.2025", "0.14.2": "12.12.2025", "0.15.0": "15.06.2026", + "0.15.1": none, ) // Converts the human-editable format above into typed (version, datetime) From 2d52568d1d534f10aa2c505b25da41e72f799d9e Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 17 Jul 2026 10:20:05 +0200 Subject: [PATCH 023/105] Bump version on main The tagged commit itself is on the 0.15 branch. --- Cargo.lock | 50 ++++++++++++++++---------------- Cargo.toml | 40 ++++++++++++------------- docs/content/changelog/index.typ | 2 +- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0b6c08545e4..cabb2a86e684 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2866,7 +2866,7 @@ dependencies = [ [[package]] name = "test-wrapper" -version = "0.15.0" +version = "0.15.1" [[package]] name = "thin-vec" @@ -3057,7 +3057,7 @@ checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typst" -version = "0.15.0" +version = "0.15.1" dependencies = [ "arrayvec", "comemo", @@ -3076,15 +3076,15 @@ dependencies = [ [[package]] name = "typst-assets" -version = "0.15.0" -source = "git+https://github.com/typst/typst-assets?rev=74217e4#74217e4a4c731ceacff20208a4148ea52e7bab77" +version = "0.15.1" +source = "git+https://github.com/typst/typst-assets?rev=94dcb99#94dcb990edb36311ef85162baa26e4a8205e0bf0" dependencies = [ "bitflags 2.11.1", ] [[package]] name = "typst-bundle" -version = "0.15.0" +version = "0.15.1" dependencies = [ "comemo", "ecow", @@ -3106,7 +3106,7 @@ dependencies = [ [[package]] name = "typst-cli" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "clap", @@ -3152,12 +3152,12 @@ dependencies = [ [[package]] name = "typst-dev-assets" -version = "0.15.0" -source = "git+https://github.com/typst/typst-dev-assets?rev=f305ed3#f305ed3e236c153be0f6dab23e1d18bf0b5f0bc7" +version = "0.15.1" +source = "git+https://github.com/typst/typst-dev-assets?rev=79ba7c3#79ba7c354e39d9cd0a074bc41becc05f0119eee3" [[package]] name = "typst-docs" -version = "0.15.0" +version = "0.15.1" dependencies = [ "az", "base64", @@ -3195,7 +3195,7 @@ dependencies = [ [[package]] name = "typst-eval" -version = "0.15.0" +version = "0.15.1" dependencies = [ "comemo", "ecow", @@ -3213,7 +3213,7 @@ dependencies = [ [[package]] name = "typst-fuzz" -version = "0.15.0" +version = "0.15.1" dependencies = [ "comemo", "libfuzzer-sys", @@ -3229,7 +3229,7 @@ dependencies = [ [[package]] name = "typst-html" -version = "0.15.0" +version = "0.15.1" dependencies = [ "az", "bumpalo", @@ -3250,7 +3250,7 @@ dependencies = [ [[package]] name = "typst-ide" -version = "0.15.0" +version = "0.15.1" dependencies = [ "comemo", "ecow", @@ -3270,7 +3270,7 @@ dependencies = [ [[package]] name = "typst-kit" -version = "0.15.0" +version = "0.15.1" dependencies = [ "chrono", "codespan-reporting", @@ -3305,7 +3305,7 @@ dependencies = [ [[package]] name = "typst-layout" -version = "0.15.0" +version = "0.15.1" dependencies = [ "az", "bumpalo", @@ -3339,7 +3339,7 @@ dependencies = [ [[package]] name = "typst-library" -version = "0.15.0" +version = "0.15.1" dependencies = [ "arrayvec", "az", @@ -3405,7 +3405,7 @@ dependencies = [ [[package]] name = "typst-macros" -version = "0.15.0" +version = "0.15.1" dependencies = [ "heck", "proc-macro2", @@ -3415,7 +3415,7 @@ dependencies = [ [[package]] name = "typst-pdf" -version = "0.15.0" +version = "0.15.1" dependencies = [ "az", "bytemuck", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "typst-realize" -version = "0.15.0" +version = "0.15.1" dependencies = [ "arrayvec", "bumpalo", @@ -3459,7 +3459,7 @@ dependencies = [ [[package]] name = "typst-render" -version = "0.15.0" +version = "0.15.1" dependencies = [ "bytemuck", "comemo", @@ -3480,7 +3480,7 @@ dependencies = [ [[package]] name = "typst-svg" -version = "0.15.0" +version = "0.15.1" dependencies = [ "base64", "comemo", @@ -3505,7 +3505,7 @@ dependencies = [ [[package]] name = "typst-syntax" -version = "0.15.0" +version = "0.15.1" dependencies = [ "ecow", "rustc-hash", @@ -3522,7 +3522,7 @@ dependencies = [ [[package]] name = "typst-tests" -version = "0.15.0" +version = "0.15.1" dependencies = [ "base64", "bitflags 2.11.1", @@ -3566,7 +3566,7 @@ dependencies = [ [[package]] name = "typst-timing" -version = "0.15.0" +version = "0.15.1" dependencies = [ "parking_lot", "serde", @@ -3576,7 +3576,7 @@ dependencies = [ [[package]] name = "typst-utils" -version = "0.15.0" +version = "0.15.1" dependencies = [ "libm", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index c1351a8d33dd..d94f9c220568 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ default-members = ["crates/typst-cli"] resolver = "2" [workspace.package] -version = "0.15.0" +version = "0.15.1" rust-version = "1.92" # also change in ci.yml authors = ["The Typst Project Developers"] edition = "2024" @@ -16,25 +16,25 @@ keywords = ["typst"] readme = "README.md" [workspace.dependencies] -typst = { path = "crates/typst", version = "0.15.0" } -typst-bundle = { path = "crates/typst-bundle", version = "0.15.0" } -typst-cli = { path = "crates/typst-cli", version = "0.15.0" } -typst-eval = { path = "crates/typst-eval", version = "0.15.0" } -typst-html = { path = "crates/typst-html", version = "0.15.0" } -typst-ide = { path = "crates/typst-ide", version = "0.15.0" } -typst-kit = { path = "crates/typst-kit", version = "0.15.0", default-features = false } -typst-layout = { path = "crates/typst-layout", version = "0.15.0" } -typst-library = { path = "crates/typst-library", version = "0.15.0" } -typst-macros = { path = "crates/typst-macros", version = "0.15.0" } -typst-pdf = { path = "crates/typst-pdf", version = "0.15.0" } -typst-realize = { path = "crates/typst-realize", version = "0.15.0" } -typst-render = { path = "crates/typst-render", version = "0.15.0" } -typst-svg = { path = "crates/typst-svg", version = "0.15.0" } -typst-syntax = { path = "crates/typst-syntax", version = "0.15.0" } -typst-timing = { path = "crates/typst-timing", version = "0.15.0" } -typst-utils = { path = "crates/typst-utils", version = "0.15.0" } -typst-assets = { git = "https://github.com/typst/typst-assets", rev = "74217e4" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "f305ed3" } +typst = { path = "crates/typst", version = "0.15.1" } +typst-bundle = { path = "crates/typst-bundle", version = "0.15.1" } +typst-cli = { path = "crates/typst-cli", version = "0.15.1" } +typst-eval = { path = "crates/typst-eval", version = "0.15.1" } +typst-html = { path = "crates/typst-html", version = "0.15.1" } +typst-ide = { path = "crates/typst-ide", version = "0.15.1" } +typst-kit = { path = "crates/typst-kit", version = "0.15.1", default-features = false } +typst-layout = { path = "crates/typst-layout", version = "0.15.1" } +typst-library = { path = "crates/typst-library", version = "0.15.1" } +typst-macros = { path = "crates/typst-macros", version = "0.15.1" } +typst-pdf = { path = "crates/typst-pdf", version = "0.15.1" } +typst-realize = { path = "crates/typst-realize", version = "0.15.1" } +typst-render = { path = "crates/typst-render", version = "0.15.1" } +typst-svg = { path = "crates/typst-svg", version = "0.15.1" } +typst-syntax = { path = "crates/typst-syntax", version = "0.15.1" } +typst-timing = { path = "crates/typst-timing", version = "0.15.1" } +typst-utils = { path = "crates/typst-utils", version = "0.15.1" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "94dcb99" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "79ba7c3" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" diff --git a/docs/content/changelog/index.typ b/docs/content/changelog/index.typ index f9e644959f1d..d15e0faa4cd9 100644 --- a/docs/content/changelog/index.typ +++ b/docs/content/changelog/index.typ @@ -24,7 +24,7 @@ "0.14.1": "03.12.2025", "0.14.2": "12.12.2025", "0.15.0": "15.06.2026", - "0.15.1": none, + "0.15.1": "17.07.2026", ) // Converts the human-editable format above into typed (version, datetime) From 865ed0188b2cf9bc1eb0bac44c4caf8fdcd79925 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 17 Jul 2026 14:45:57 +0200 Subject: [PATCH 024/105] Bump `spin` (#8656) --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cabb2a86e684..c563d1c24e55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2690,9 +2690,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "stable_deref_trait" From 89495172a5b6a697cbf2d6c560efb2da9dfd173b Mon Sep 17 00:00:00 2001 From: Philipp Niedermayer Date: Fri, 17 Jul 2026 19:09:08 +0200 Subject: [PATCH 025/105] Implement column balancing (#8207) --- crates/typst-layout/src/flow/compose.rs | 102 ++++++++---- crates/typst-layout/src/flow/distribute.rs | 114 +++++++++----- crates/typst-layout/src/flow/mod.rs | 54 +++++-- crates/typst-layout/src/pages/run.rs | 9 +- crates/typst-library/src/layout/columns.rs | 37 ++++- tests/ref/pdf/hashes.txt | 14 ++ tests/ref/render/columns-balanced-blocks.png | Bin 0 -> 145 bytes tests/ref/render/columns-balanced-figures.png | Bin 0 -> 15652 bytes .../columns-balanced-footnote-overflow.png | Bin 0 -> 18289 bytes .../ref/render/columns-balanced-footnotes.png | Bin 0 -> 19689 bytes .../render/columns-balanced-leftover-2-3.png | Bin 0 -> 13956 bytes .../render/columns-balanced-leftover-4.png | Bin 0 -> 10454 bytes .../render/columns-balanced-leftover-5.png | Bin 0 -> 11596 bytes .../render/columns-balanced-leftover-6.png | Bin 0 -> 14166 bytes .../render/columns-balanced-leftover-7.png | Bin 0 -> 15661 bytes tests/ref/render/columns-balanced-lists.png | Bin 0 -> 4565 bytes .../ref/render/columns-balanced-pagebreak.png | Bin 0 -> 6964 bytes tests/ref/render/columns-balanced-tables.png | Bin 0 -> 1660 bytes tests/ref/render/columns-balanced-widow.png | Bin 0 -> 6193 bytes tests/ref/render/columns-balanced.png | Bin 0 -> 10210 bytes tests/ref/svg/hashes.txt | 16 +- tests/suite/layout/columns.typ | 146 ++++++++++++++++++ 22 files changed, 399 insertions(+), 93 deletions(-) create mode 100644 tests/ref/render/columns-balanced-blocks.png create mode 100644 tests/ref/render/columns-balanced-figures.png create mode 100644 tests/ref/render/columns-balanced-footnote-overflow.png create mode 100644 tests/ref/render/columns-balanced-footnotes.png create mode 100644 tests/ref/render/columns-balanced-leftover-2-3.png create mode 100644 tests/ref/render/columns-balanced-leftover-4.png create mode 100644 tests/ref/render/columns-balanced-leftover-5.png create mode 100644 tests/ref/render/columns-balanced-leftover-6.png create mode 100644 tests/ref/render/columns-balanced-leftover-7.png create mode 100644 tests/ref/render/columns-balanced-lists.png create mode 100644 tests/ref/render/columns-balanced-pagebreak.png create mode 100644 tests/ref/render/columns-balanced-tables.png create mode 100644 tests/ref/render/columns-balanced-widow.png create mode 100644 tests/ref/render/columns-balanced.png diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs index 5cbd0f4d1258..ab3ab284a252 100644 --- a/crates/typst-layout/src/flow/compose.rs +++ b/crates/typst-layout/src/flow/compose.rs @@ -46,6 +46,7 @@ pub fn compose( column: 0, page_insertions: Insertions::default(), column_insertions: Insertions::default(), + column_balancing_height: None, work, footnote_spill: None, footnote_queue: vec![], @@ -69,6 +70,7 @@ pub struct Composer<'a, 'b, 'x, 'y> { page_base: Size, page_insertions: Insertions<'a, 'b>, column_insertions: Insertions<'a, 'b>, + column_balancing_height: Option, // These are here because they have to survive relayout (we could lose the // footnotes otherwise). For floats, we revisit them anyway, so it's okay to // use `work.floats` directly. This is not super clean; probably there's a @@ -101,14 +103,14 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { }; drop(checkpoint); - Ok(self.page_insertions.finalize(self.work, self.config, output)) + Ok(self.page_insertions.finalize(self.work, self.config, output, None)) } /// Lay out the inner contents of a container/page. fn page_contents(&mut self, locator: Locator, regions: Regions) -> FlowResult

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 M07*qoM6N<$f`xESS^xk5 literal 0 HcmV?d00001 diff --git a/tests/ref/render/columns-balanced-leftover-5.png b/tests/ref/render/columns-balanced-leftover-5.png new file mode 100644 index 0000000000000000000000000000000000000000..53d467aeded901922b063ceeec0ed49542b3e763 GIT binary patch literal 11596 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 KMNUMnLSTZBanwBk literal 0 HcmV?d00001 diff --git a/tests/ref/render/columns-balanced-leftover-6.png b/tests/ref/render/columns-balanced-leftover-6.png new file mode 100644 index 0000000000000000000000000000000000000000..fc9a7d7f5401fe5c3ebd5440189f66a28506f838 GIT binary patch literal 14166 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 literal 0 HcmV?d00001 diff --git a/tests/ref/render/columns-balanced-leftover-7.png b/tests/ref/render/columns-balanced-leftover-7.png new file mode 100644 index 0000000000000000000000000000000000000000..ec07f417e1637dbfd7b4ab41dfb5085388f29ad9 GIT binary patch literal 15661 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 TPW6iB00000NkvXXu0mjf)Q(3T literal 0 HcmV?d00001 diff --git a/tests/ref/render/columns-balanced-lists.png b/tests/ref/render/columns-balanced-lists.png new file mode 100644 index 0000000000000000000000000000000000000000..41860e7a58fa360dde499dce3d29e2cd78f8cd77 GIT binary patch literal 4565 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 literal 0 HcmV?d00001 diff --git a/tests/ref/render/columns-balanced-tables.png b/tests/ref/render/columns-balanced-tables.png new file mode 100644 index 0000000000000000000000000000000000000000..8e49fa597b0788eb2b05cea04e9d8e7102cd1887 GIT binary patch literal 1660 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 W|Fv2Y5^z+V~ED03rpv3JX48L+7` zMp_=&X5e4y{k%uim6$1?JYb}8`iW@`WtQGZY1Z`7O#fGFJc>YT{Kc*+?nNMHw8I>+ zlUv!r*E52c-TU$=>6M!`+u3n&`Q7m4(c`$?$Ep|@;HJfSAx;j1stY4A8^nHO=M+>% zduD;~Dd$}YhB52}d#HM38mqTMy&=`Bpzd9F7_MBu-S!PW= z7JDNz2&1A*~`<3WSOjDG(}B cr0DO{Z%&jbbJsZQ`2YX_07*qoM6N<$f{6)E`~Uy| literal 0 HcmV?d00001 diff --git a/tests/ref/svg/hashes.txt b/tests/ref/svg/hashes.txt index bedd3b4f008a..2942fa243f72 100644 --- a/tests/ref/svg/hashes.txt +++ b/tests/ref/svg/hashes.txt @@ -99,6 +99,20 @@ ad938013f7e83349fba1ea361db7f246 closure-without-params-non-atomic 79c68a827aaded34d36c0612fe10b102 color-rotate-hue deeba65cb9f265841a1f94c0a472f159 color-saturation a2bc7d8dcb908647fa9494a451d7d592 color-spaces +8492ecc566615fa988840b12dd09077d columns-balanced +bd1233a376d1fffe7096f5a23434a0f1 columns-balanced-blocks +06a475e9fdffb6516cd96db1cc41e735 columns-balanced-figures +92f23d2f6eae88e4250d62b8115b0b1d columns-balanced-footnote-overflow +c0ce14889d60fcb5d8a064a97a97c24e columns-balanced-footnotes +90bc505fed9240df2ac9ed34be7b8af5 columns-balanced-leftover-2-3 +83032278eeba68bc31069018ad46639d columns-balanced-leftover-4 +cd2ca6352c13d1e349284452c0afcf8d columns-balanced-leftover-5 +f1cc4106fc313d5a12180f5741f23338 columns-balanced-leftover-6 +e42ea92abd4babbce30383b956f3646a columns-balanced-leftover-7 +97939b351858b9cf563dd289e6722a6a columns-balanced-lists +034dcf0195cba7f6a69ce398abbc66f1 columns-balanced-pagebreak +3672488e8d7ee67e6fbe808a42b3b0fb columns-balanced-tables +70bccf67bfce62521198e0622d9e1891 columns-balanced-widow cc4b690f5771b9286e7a833551e0109e columns-colbreak-after-place f6db5c95be1a9a92e4b794403580fa64 columns-empty-second-column 4b8e180702317152a96f132736bd60f5 columns-in-auto-sized-rect @@ -316,7 +330,7 @@ cae9e4e87ce63e0a3be8cde298cf5427 gradient-math-conic de4eb48f3300499bec530dd5b48c9a4a gradient-math-misc b0ce942acd695b5909c8204775be8ddc gradient-math-radial ec0a500c006cdc8066a2e791722b2b8c gradient-math-root -344c967eb31d9b931ab6744b242edb4d gradient-math-underover +cbf1869a9e8236f466068a8061eabb43 gradient-math-underover f21b13e2c0d00f1450f411db9bc01a4a gradient-negative-square-angles e6107d1880debe4809ae9dfe60ff62c4 gradient-presets 1bd528444029a3d1114bff07aa446906 gradient-radial-aspect-ratio diff --git a/tests/suite/layout/columns.typ b/tests/suite/layout/columns.typ index 9f29f784b078..49a7b2905de6 100644 --- a/tests/suite/layout/columns.typ +++ b/tests/suite/layout/columns.typ @@ -129,3 +129,149 @@ Hallo A #colbreak(weak: true) B + +--- columns-balanced paged --- +#set page(width: 300pt) +#set par(justify: true) +#rect(inset: 0pt, outset: 0pt, stroke: yellow, + columns(3, balanced: true, lorem(40)) +) + +--- columns-balanced-leftover-2-3 paged --- +// Test how column balancing distributes leftover lines across columns +#set page(width: 300pt) +#set text(size: 7pt) +#set par(justify: true) +#for (n, mm) in ((2, (31, 37)), (3, (29, 32, 37))) { + for m in mm { + rect(inset: 0pt, outset: 0pt, stroke: gray, + columns(n, balanced: true, lorem(m)) + ) + } +} + +--- columns-balanced-leftover-4 paged --- +// Test how column balancing distributes leftover lines across columns +#set page(width: 300pt) +#set text(size: 7pt) +#set par(justify: true) +#for (n, mm) in ((4, (26, 29, 32, 35)),) { + for m in mm { + rect(inset: 0pt, outset: 0pt, stroke: gray, + columns(n, balanced: true, lorem(m)) + ) + } +} + +--- columns-balanced-leftover-5 paged --- +// Test how column balancing distributes leftover lines across columns +#set page(width: 300pt) +#set text(size: 7pt) +#set par(justify: true) +#for (n, mm) in ((5, (21, 25, 27, 29, 31)),) { + for m in mm { + rect(inset: 0pt, outset: 0pt, stroke: gray, + columns(n, balanced: true, lorem(m)) + ) + } +} + +--- columns-balanced-leftover-6 paged --- +// Test how column balancing distributes leftover lines across columns +#set page(width: 350pt) +#set text(size: 6pt) +#set par(justify: true) +#for (n, mm) in ((6, (29, 31, 33, 35, 37, 39)),) { + for m in mm { + rect(inset: 0pt, outset: 0pt, stroke: gray, + columns(n, balanced: true, lorem(m)) + ) + } +} + +--- columns-balanced-leftover-7 paged --- +// Test how column balancing distributes leftover lines across columns +#set page(width: 350pt) +#set text(size: 6pt) +#set text(hyphenate: true) +#set par(justify: true) +#for (n, mm) in ((7, (28, 30, 31, 32, 34, 36, 38)),) { + for m in mm { + rect(inset: 0pt, outset: 0pt, stroke: gray, + columns(n, balanced: true, lorem(m)) + ) + } +} + +--- columns-balanced-widow paged --- +#set page(width: 300pt, height: 100pt, columns: 6) +#set text(size: 7pt) +#set par(justify: true) +#set columns(balanced: true) +#lorem(68) // just enough text to trigger a widow if not balanced correctly + +--- columns-balanced-pagebreak paged --- +#set page(width: 300pt, height: 90pt, margin: 5pt, columns: 3) +#set text(hyphenate: true) +#set par(justify: true) +#set columns(balanced: true) +#lorem(35) +#pagebreak() +#lorem(18) + +--- columns-balanced-figures paged --- +#set page(width: 300pt, height: 190pt, margin: 5pt, columns: 3) +#set columns(balanced: true) +#set text(hyphenate: true) +#set par(justify: true) +#figure(rect(height: 30pt, width: 100%)) +#lorem(25)#footnote[Footer] +#figure(rect(height: 30pt, width: 100%), placement: bottom) +#lorem(35) +#figure(rect(height: 15pt, width: 80%), scope: "parent", placement: bottom) + +--- columns-balanced-footnotes paged --- +#set page(width: 300pt, height: 90pt, margin: 5pt, columns: 3) +#set text(hyphenate: true) +#set par(justify: true) +#set columns(balanced: true) +#lorem(10)#footnote[Footer] +#lorem(50)#footnote[Footer] +#lorem(22) + +--- columns-balanced-footnote-overflow paged --- +#set page(width: 300pt, height: 150pt, columns: 2) +#set columns(balanced: true) +#lorem(10) +#footnote(lorem(30)) +#lorem(45) + +--- columns-balanced-blocks paged --- +#set page(width: 100pt, height: auto, margin: 5pt, columns: 3) +#set columns(balanced: true) +#set block(width: 100%, fill: gray, spacing: 1pt) +#for i in range(10){block(height: 3pt * (1+i/5))} +#pagebreak() +#set block(breakable: false) +#for i in range(10){block(height: 3pt * (1+i/5))} + +--- columns-balanced-tables paged --- +// Check that balancing is repeated when +// the height changes due to the repeated header +#set page(width: 100pt, margin: 5pt, columns: 2) +#set columns(balanced: true) +#table(columns: 2, + table.header([$x$], [$x^2$]), + ..for x in range(1,11){ + ([#x], [#(x*x)]) + } +) + +--- columns-balanced-lists paged --- +// Check that breakable blocks are balanced correctly via `multi` and `multi_spill` +#set page(width: 350pt, columns: 4) +#set columns(balanced: true) +#{ + 8 * [- #lorem(5)] +} + From 98fec489a67551728aa52da43eaceadb72b1d962 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Mon, 20 Jul 2026 08:39:32 +0100 Subject: [PATCH 026/105] Remove deprecated array casts for `enum.item` and `terms.item` (#8659) --- crates/typst-library/src/model/enum.rs | 26 +------------------- crates/typst-library/src/model/terms.rs | 24 +----------------- tests/ref/pdf/hashes.txt | 2 -- tests/ref/render/enum-array-deprecated.png | Bin 244 -> 0 bytes tests/ref/render/terms-array-deprecated.png | Bin 342 -> 0 bytes tests/ref/svg/hashes.txt | 2 -- tests/suite/model/enum.typ | 6 ----- tests/suite/model/terms.typ | 6 ----- 8 files changed, 2 insertions(+), 64 deletions(-) delete mode 100644 tests/ref/render/enum-array-deprecated.png delete mode 100644 tests/ref/render/terms-array-deprecated.png diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs index c0f6f0c77f3c..69d403a9168c 100644 --- a/crates/typst-library/src/model/enum.rs +++ b/crates/typst-library/src/model/enum.rs @@ -2,10 +2,7 @@ use std::str::FromStr; use smallvec::SmallVec; -use crate::diag::{bail, warning}; -use crate::foundations::{ - Array, Content, Packed, Reflect, Smart, Styles, cast, elem, scope, -}; +use crate::foundations::{Content, Packed, Smart, Styles, cast, elem, scope}; use crate::layout::{Alignment, Em, HAlignment, Length}; use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern}; @@ -213,19 +210,6 @@ pub struct EnumElem { /// ) [+ #phase] /// ``` #[variadic] - #[parse( - for item in &args.items { - if item.name.is_none() && Array::castable(&item.value.v) { - engine.sink.warn(warning!( - item.value.span, - "implicit conversion from array to `enum.item` is deprecated"; - hint: "use `enum.item(number)[body]` instead"; - hint: "this conversion was never documented and is being phased out"; - )); - } - } - args.all()? - )] pub children: Vec>, /// The numbers of parent items. @@ -255,14 +239,6 @@ pub struct EnumItem { cast! { EnumItem, - array: Array => { - let mut iter = array.into_iter(); - let (number, body) = match (iter.next(), iter.next(), iter.next()) { - (Some(a), Some(b), None) => (a.cast()?, b.cast()?), - _ => bail!("array must contain exactly two entries"), - }; - Self::new(body).with_number(number) - }, v: Content => v.unpack::().unwrap_or_else(Self::new), } diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs index 2393dec0afdd..297800d240aa 100644 --- a/crates/typst-library/src/model/terms.rs +++ b/crates/typst-library/src/model/terms.rs @@ -1,6 +1,5 @@ -use crate::diag::{bail, warning}; use crate::foundations::{ - Array, Content, NativeElement, Packed, Reflect, Smart, Styles, cast, elem, scope, + Content, NativeElement, Packed, Smart, Styles, cast, elem, scope, }; use crate::layout::{Em, HElem, Length}; use crate::model::{ListItemLike, ListLike}; @@ -97,19 +96,6 @@ pub struct TermsElem { /// ) [/ #product: Born in #year.] /// ``` #[variadic] - #[parse( - for item in &args.items { - if item.name.is_none() && Array::castable(&item.value.v) { - engine.sink.warn(warning!( - item.value.span, - "implicit conversion from array to `terms.item` is deprecated"; - hint: "use `terms.item(term, description)` instead"; - hint: "this conversion was never documented and is being phased out"; - )); - } - } - args.all()? - )] pub children: Vec>, /// Whether we are currently within a term list. @@ -138,14 +124,6 @@ pub struct TermItem { cast! { TermItem, - array: Array => { - let mut iter = array.into_iter(); - let (term, description) = match (iter.next(), iter.next(), iter.next()) { - (Some(a), Some(b), None) => (a.cast()?, b.cast()?), - _ => bail!("array must contain exactly two entries"), - }; - Self::new(term, description) - }, v: Content => v.unpack::().map_err(|_| "expected term item or array")?, } diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index 4fffc9e26031..7c41e5fc0ab6 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -196,7 +196,6 @@ dd9b5fe57e921c62917743d9272d68ac ellipse 7032b2bfab9dd86c1b4c4a6ea99765a6 emph-and-strong-call-in-word ee4b834945e3d4ee98adbb83cc24e440 emph-double-underscore-empty-hint 5e5bd91b7531b364c972a4b54c5bca3e emph-syntax -c9a7bcc0995006716a0f6ea019f0f381 enum-array-deprecated 1fbee69c2a83c9ebad54eaa13ff4a2e8 enum-built-in-loop 25c9b6cb4ef0514e8e10d9fcb175f04c enum-function-call ab546e776c589625d584cc2fb8d3f931 enum-in-align-centering @@ -1842,7 +1841,6 @@ a7abe27f562feb7cad67977e16f9e51a table-inset 2feba4dabc7ec7f87ddd683ea0003ad7 table-newlines c20d7b0452399cb36979023ae3ec8933 table-stroke-vline-position-left-and-right 6bc2fa9b013cd685f8a20fe24f16a51a tags-textual -ca206876eddba15d455f9e742136f5ad terms-array-deprecated 2831f586aff16984721f700f4d22e909 terms-built-in-loop 4977ad3d17be95afcc2b9f87e76a8000 terms-constructor 13aa51a5ffd1b7ce9eb4aed4252a02df terms-grid diff --git a/tests/ref/render/enum-array-deprecated.png b/tests/ref/render/enum-array-deprecated.png deleted file mode 100644 index aa9f06588d31948461aecc42edf8c51b2375d850..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 244 zcmV4xlu@=sMO-qaa_9(*Hj~zhH_zSn!leRv#fwP|5 ze_!#xb=vEwRVn``_if#%znESYYcGlYzoqVf>YVSl!oFR4<4rdoJO2Ooe`nqQtaJbW z_nFYz;(sdt|DX8=X5Kah5*teYCmjC2?ahIv56)j{Y4PHmP5&o91~Vt-9s2*jt?GPP u=l=^mbKdL#e&FoKKgsJ4ReNKnd|F(ZH{*NzIw)mCck^j&6S7EA2Iq?7J zkH2tk&Iu}8+|Bmq|9@A{n*j}Fu6O?5sz^Nul}O+HV%IhAU(4zX_8wW3|G~ND3Uw{c z7X}h(^8cNJei^m?H<(na53{&#^6dYnf1fIU{S$NH?El=;RQB;Y#uxwp`}zF$%>D0D z^qgtRu^TXp5B~r6-|F8hzyJSlyOvPj;vaSe{~t-5{qKGnWK*Heb z1DF5*7ybA8UU29ul~eWCSuGuR|6eJnxRGD-;9`2wV`!=_Y5n&9T;BSHg=hcwF5de8 oMA5_10%X+UQHw_{9+4IU07n5-7RXR&$N&HU07*qoM6N<$f Date: Thu, 23 Jul 2026 21:29:11 +0530 Subject: [PATCH 027/105] Escape `&` and `<` in SVG attribute values (#8510) --- crates/typst-svg/src/write.rs | 57 +++++++++++++++++++++-- tests/ref/pdf/hashes.txt | 1 + tests/ref/render/link-url-svg-escape.png | Bin 0 -> 91 bytes tests/ref/svg/hashes.txt | 1 + tests/suite/model/link.typ | 5 ++ 5 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/ref/render/link-url-svg-escape.png diff --git a/crates/typst-svg/src/write.rs b/crates/typst-svg/src/write.rs index eb54af1fcffd..7c5f32e56bbd 100644 --- a/crates/typst-svg/src/write.rs +++ b/crates/typst-svg/src/write.rs @@ -90,10 +90,12 @@ impl Drop for LazySvgElem<'_, '_> { } pub trait SvgWrite: Sized { - /// Write a string, escaping is handled by [`xmlwriter`]. + /// Write a string. When writing to an attribute value, XML metacharacters + /// are escaped. fn push_str(&mut self, value: &str); - /// Write a character, escaping is handled by [`xmlwriter`]. + /// Write a character. When writing to an attribute value, XML + /// metacharacters are escaped. fn push_char(&mut self, value: char) { self.push_str(value.encode_utf8(&mut [0; 4])); } @@ -151,13 +153,60 @@ impl<'a, T> SvgFormatter<'a, T> { impl SvgWrite for SvgFormatter<'_, Vec> { fn push_str(&mut self, value: &str) { - self.buf.extend_from_slice(value.as_bytes()); + escape_str(value, |str| self.buf.extend_from_slice(str.as_bytes())); } } impl SvgWrite for SvgFormatter<'_, EcoString> { fn push_str(&mut self, value: &str) { - self.buf.push_str(value); + escape_str(value, |str| self.buf.push_str(str)); + } +} + +/// Append `value` to a buffer, escaping the XML metacharacters that +/// `xmlwriter`'s raw attribute writer leaves untouched (it only escapes the +/// quotation mark). Otherwise a value such as a link URL containing `&` or `<` +/// yields malformed SVG. Runs of non-escaped characters are appended in one go. +fn escape_str(mut value: &str, mut append: impl FnMut(&str)) { + while let Some((i, c)) = value + .bytes() + .enumerate() + .find_map(|(i, b)| EscapedChar::from_byte(b).map(|c| (i, c))) + { + append(&value[..i]); + append(c.escape_sequence()); + + // Escaped characters are always one byte. + value = &value[i + 1..]; + } + if !value.is_empty() { + append(value); + } +} + +/// A character that should be escaped in XML attribute values. +#[derive(Copy, Clone)] +enum EscapedChar { + /// `&` + Amp, + /// `<` + Lt, +} + +impl EscapedChar { + const fn from_byte(byte: u8) -> Option { + match byte { + b'&' => Some(Self::Amp), + b'<' => Some(Self::Lt), + _ => None, + } + } + + const fn escape_sequence(self) -> &'static str { + match self { + Self::Amp => "&", + Self::Lt => "<", + } } } diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index 7c41e5fc0ab6..ef3cec9808c8 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -969,6 +969,7 @@ d1178fbcd79a21782aa1279d5f6348dd link-to-label cd337be24b51dceb0460c4dde2307b1f link-to-page 0388a6126d167f18dfd0556c758ac92e link-trailing-period fca251f69334ee095b338330e1839b1e link-transformed +c9d307f6eff5463d6d3c9c8e8dedda96 link-url-svg-escape da43f42b62580f4870ea61b5fc63a648 list-attached 7a2ba6da6fbe5ccae2cf33c7d203832f list-attached-above-spacing d50762fbb69a74768eae7cf9c5da10a0 list-baseline-big-marker diff --git a/tests/ref/render/link-url-svg-escape.png b/tests/ref/render/link-url-svg-escape.png new file mode 100644 index 0000000000000000000000000000000000000000..e90a5341ed64ec5a5d083f7a1bffdb5b1c7e022a GIT binary patch literal 91 zcmeAS@N?(olHy`uVBq!ia0vp^6+kS>$P6S8FjeFMDYgKg5ZC|z|Fdxjv?!V`0E!5B nx;TbZ+)9@Ca-c8iUjP%sZx%*#hDBCWfpQF")[#box(width: 5pt, height: 5pt, fill: black)] + --- link-to-page paged --- // Link to page one. #link((page: 1, x: 10pt, y: 20pt))[Back to the start] From 8f5a0b700d3bab0b7b5cba1f949a173c24b7322f Mon Sep 17 00:00:00 2001 From: slipperking <183034529+slipperking@users.noreply.github.com> Date: Thu, 23 Jul 2026 10:10:40 -0700 Subject: [PATCH 028/105] Add `angle` argument to `tiling` (#8621) Co-authored-by: Tobias Schmitz --- crates/typst-library/src/visualize/tiling.rs | 49 +++++++++++++++-- crates/typst-pdf/src/paint.rs | 2 +- crates/typst-render/src/paint.rs | 11 ++-- crates/typst-svg/src/paint.rs | 23 +++++--- tests/ref/pdf/hashes.txt | 4 ++ tests/ref/render/tiling-angle-with-offset.png | Bin 0 -> 715 bytes ...angle-with-relative-offset-and-spacing.png | Bin 0 -> 684 bytes tests/ref/render/tiling-angle.png | Bin 0 -> 674 bytes tests/ref/render/tiling-text-offset-angle.png | Bin 0 -> 13278 bytes tests/ref/svg/hashes.txt | 48 ++++++++-------- tests/suite/visualize/tiling.typ | 52 ++++++++++++++++++ 11 files changed, 148 insertions(+), 41 deletions(-) create mode 100644 tests/ref/render/tiling-angle-with-offset.png create mode 100644 tests/ref/render/tiling-angle-with-relative-offset-and-spacing.png create mode 100644 tests/ref/render/tiling-angle.png create mode 100644 tests/ref/render/tiling-text-offset-angle.png diff --git a/crates/typst-library/src/visualize/tiling.rs b/crates/typst-library/src/visualize/tiling.rs index 9b941531d498..f60ec8a47403 100644 --- a/crates/typst-library/src/visualize/tiling.rs +++ b/crates/typst-library/src/visualize/tiling.rs @@ -9,7 +9,7 @@ use crate::diag::{SourceResult, bail}; use crate::engine::Engine; use crate::foundations::{Content, Repr, Resolve, Smart, StyleChain, func, scope, ty}; use crate::introspection::Locator; -use crate::layout::{Abs, Axes, Frame, Length, Region, Rel, Size}; +use crate::layout::{Abs, Angle, Axes, Frame, Length, Region, Rel, Size, Transform}; use crate::visualize::RelativeTo; /// A repeating tiling fill. @@ -19,8 +19,9 @@ use crate::visualize::RelativeTo; /// filled or stroked. The pattern is defined by a tile /// @tiling.constructor.size[`size`] and a body defining the content of each /// cell. You can also add horizontal or vertical -/// @tiling.constructor.spacing[`spacing`] between the cells of the tiling and -/// @tiling.constructor.offset[`offset`] the starting position of the tiling. +/// @tiling.constructor.spacing[`spacing`] between the cells of the tiling. +/// The @tiling.constructor.offset[`offset`] and +/// @tiling.constructor.angle[`angle`] determine the placement of the tiling. /// /// = Example /// ```example @@ -68,11 +69,14 @@ struct TilingInner { spacing: Size, /// The tiling's tile offset. offset: Size, + /// The tiling's tile angle. + angle: Angle, /// The tiling's relative transform. relative: Smart, } #[scope] +#[expect(clippy::too_many_arguments)] impl Tiling { /// Construct a new tiling. /// @@ -164,6 +168,21 @@ impl Tiling { #[named] #[default(Spanned::new(Axes::splat(Rel::zero()), Span::detached()))] offset: Spanned>>, + /// Rotates the tiles and the grid clockwise about the offset coordinates + /// by the specified angle. The rotation is applied after the offset. + /// + /// ```example + /// #let pat = tiling( + /// size: (20pt, 20pt), + /// angle: 45deg, + /// line(start: (0%, 50%), end: (100%, 50%)), + /// ) + /// + /// #rect(width: 100%, height: 60pt, fill: pat) + /// ``` + #[named] + #[default(Spanned::detached(Angle::zero()))] + angle: Spanned, /// Determines relative to which element's bounding box the tiling is /// drawn. /// @@ -200,7 +219,7 @@ impl Tiling { /// table.cell(fill: pat, none), /// /// // These two are both page-relative, so the - /// // pattern is continous. + /// // pattern is continuous. /// table.cell(fill: pat-with-parent, none), /// table.cell(fill: pat-with-parent, none), /// ) @@ -252,6 +271,10 @@ impl Tiling { bail!(offset.span, "tile offset must be finite"); } + if !angle.v.is_finite() { + bail!(angle.span, "tile angle must be finite"); + } + // The size of the frame let size = size.v.map(|l| l.map(|a| a.abs)); let region = size.unwrap_or_else(|| Axes::splat(Abs::inf())); @@ -288,6 +311,7 @@ impl Tiling { frame: LazyHash::new(frame), spacing, offset, + angle: angle.v, relative, }))) } @@ -313,6 +337,11 @@ impl Tiling { self.0.offset } + /// Return the rotation angle of the tiling. + pub fn angle(&self) -> Angle { + self.0.angle + } + /// Return the frame of the tiling. pub fn frame(&self) -> &Frame { &self.0.frame @@ -339,6 +368,13 @@ impl Tiling { if on_text { RelativeTo::Parent } else { RelativeTo::Self_ } }) } + + /// Returns the transform of the tiling from the `offset` and `angle` + /// parameters. + pub fn transform(&self) -> Transform { + Transform::translate(self.offset().x, self.offset().y) + .pre_concat(Transform::rotate(self.angle())) + } } impl Repr for Tiling { @@ -362,6 +398,11 @@ impl Repr for Tiling { out.push(')'); } + if self.0.angle != Angle::zero() { + out.push_str(", angle: "); + out.push_str(&self.0.angle.repr()); + } + out.push_str(", ..)"); out diff --git a/crates/typst-pdf/src/paint.rs b/crates/typst-pdf/src/paint.rs index 644687164656..5444f09e834d 100644 --- a/crates/typst-pdf/src/paint.rs +++ b/crates/typst-pdf/src/paint.rs @@ -170,7 +170,7 @@ fn convert_pattern( state: &State, ) -> SourceResult<(krilla::paint::Paint, u8)> { let transform = correct_transform(state, pattern.unwrap_relative(on_text)) - .pre_concat(Transform::translate(pattern.offset().x, pattern.offset().y)); + .pre_concat(pattern.transform()); let mut stream_builder = surface.stream_builder(); let mut surface = stream_builder.surface(); diff --git a/crates/typst-render/src/paint.rs b/crates/typst-render/src/paint.rs index 919913dc2bee..8cc31d0fce95 100644 --- a/crates/typst-render/src/paint.rs +++ b/crates/typst-render/src/paint.rs @@ -6,7 +6,7 @@ use typst_library::visualize::{ Geometry, Gradient, Paint, ProcessColor, RelativeTo, Shape, Tiling, }; -use crate::{AbsExt, State}; +use crate::{AbsExt, State, to_sk_transform}; /// Trait for sampling of a paint, used as a generic /// abstraction over solid colors and gradients. @@ -102,11 +102,13 @@ impl<'a> TilingSampler<'a> { RelativeTo::Self_ => sk::Transform::identity(), RelativeTo::Parent => state.container_transform.invert().unwrap(), }; + let pattern_transform = to_sk_transform(&tilings.transform()); Self { pixmap, size: (tilings.size() + tilings.spacing()) * state.pixel_per_pt as f64, - transform_to_parent: fill_transform, + transform_to_parent: fill_transform + .post_concat(pattern_transform.invert().unwrap_or_default()), pixel_per_pt: state.pixel_per_pt, } } @@ -270,10 +272,7 @@ pub fn to_sk_paint<'a>( sk::FilterQuality::Nearest, 1.0, fill_transform - .pre_translate( - tilings.offset().x.to_f32(), - tilings.offset().y.to_f32(), - ) + .pre_concat(to_sk_transform(&tilings.transform())) .pre_scale(1.0 / state.pixel_per_pt, 1.0 / state.pixel_per_pt) .pre_translate(base_offset.x.to_f32(), base_offset.y.to_f32()), ); diff --git a/crates/typst-svg/src/paint.rs b/crates/typst-svg/src/paint.rs index 5e04986fa3c2..65b93ca526a7 100644 --- a/crates/typst-svg/src/paint.rs +++ b/crates/typst-svg/src/paint.rs @@ -87,22 +87,29 @@ impl SVGRenderer<'_> { pub(super) fn push_tiling(&mut self, tiling: &Tiling, ts: Transform) -> DedupId { let tiling_size = tiling.size() + tiling.spacing(); let tiling_offset = tiling.offset(); + let tiling_angle = tiling.angle(); // Unfortunately due to a limitation of `xmlwriter`, we need to // render the frame twice: once to allocate all of the resources // that it needs and once to actually render it. let rendered = self.render_tiling_frame(&State::new(tiling_size), tiling.frame()); - // Use the rendered SVG and the tiling's size and offset as a key, since the `Tiling` - // itself includes `Location`s which aren't stable. - let tiling_id = self - .tilings - .insert_with((tiling_size, tiling_offset, rendered), || tiling.clone()); + // Use the rendered SVG and the tiling's properties as a key, since the + // `Tiling` itself includes `Location`s which aren't stable. + let tiling_id = self.tilings.insert_with( + (tiling_size, tiling_offset, tiling_angle, rendered.as_str()), + || tiling.clone(), + ); if ts.is_identity() { return tiling_id; } - let tiling_ref = TilingRef { id: tiling_id, transform: ts }; + // The tiling reference is written as a SVG `pattern` with a `href` + // attribute, which will overwrite the `patternTransform` of the + // referenced pattern. Thus any pre-existing `patternTransform` of the + // referenced pattern needs to be concatenated. + let transform = ts.pre_concat(tiling.transform()); + let tiling_ref = TilingRef { id: tiling_id, transform }; self.tiling_refs.insert_with(tiling_ref, || tiling_ref) } @@ -341,13 +348,13 @@ impl SVGRenderer<'_> { self.tilings.iter().map(|(i, p)| (i, p.clone())).collect::>() { let size = tiling.size() + tiling.spacing(); + defs.elem("pattern") .attr("id", id) .attr("width", size.x.to_pt()) .attr("height", size.y.to_pt()) - .attr("x", tiling.offset().x.to_pt()) - .attr("y", tiling.offset().y.to_pt()) .attr("patternUnits", "userSpaceOnUse") + .attr("patternTransform", SvgTransform(tiling.transform())) .attr_with("viewBox", |attr| { attr.push_nums([0.0, 0.0, size.x.to_pt(), size.y.to_pt()]); }) diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index ef3cec9808c8..ca2ea4d8b268 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -1898,6 +1898,9 @@ a1998ab2816b028e3de6b20c1c122d8e text-spacing-relative e8c38348f3c3758af450e3b6e9f39cc6 text-tracking-mark-placement 6dcee9c5d25ba3b758aa0359880cfa8a text-tracking-negative 13e055782445e90a2967be4fbc896e9b text-unknown-font-family-warning +39f9be8e858a31fb09fd547a708c2c3f tiling-angle +715f940e598b07c02925b94a80ae8797 tiling-angle-with-offset +66a337acf08c271ebb244778b62a6aa8 tiling-angle-with-relative-offset-and-spacing 2225f575b4cc95ea99e228048dea6daa tiling-line 70a2534434e265405cb4f15dac4f623c tiling-lines 0bbfa19437ad13aa77b7579124da2d56 tiling-negative-area @@ -1917,6 +1920,7 @@ f8c65043365ded3763112324650e90ac tiling-spacing-negative e235b63c455666e921af52eef37980bb tiling-stroke-relative-parent 942912a02f9875ffc25671074f85201d tiling-text ce47bdafefffeeaaf618a83e729d7016 tiling-text-fill +b5b1459fbbbd9f32ec5292b3c7e4b2fa tiling-text-offset-angle 41d3d523c15a3df9424f6cb44aafbfce tiling-with-different-size e7c9446a20f331a801262b4111c3a92d tiling-with-different-spacing 85f18dd75a49510a986af8fe84d51784 title diff --git a/tests/ref/render/tiling-angle-with-offset.png b/tests/ref/render/tiling-angle-with-offset.png new file mode 100644 index 0000000000000000000000000000000000000000..8e729a4e8dbb5b23157c53ba998817f9a46017b5 GIT binary patch literal 715 zcmeAS@N?(olHy`uVBq!ia0vp^DImiW*Z=?jH#9WV*Vi+Y++?uI zWw6R&kTqwJHDlnHVc?f$;NbJ~XZ*mxz?ACg;uunK>+KE4JYhqI)`vwbc?~-bZ9d7E zlUUljT&7_~=1r@_Q?q!hn;N*k%sFxI&x7TiyVm^@+uZTx_2rZ`8B!5XgMa*^%HNV#CqN$?Z_nmHXgPklNT#CGk$zBg@uXv(a9jk9?6dT z|0aPro@+qTLZxgVrRN$BG(=pI6p#?`Phw3pZH`#<^g>Ga_P0)(e(T0pc!*E>WPEa( zf2B2N_L@Eszgg-_GdTO`@qEj{!*OO# z4?90Ild<}Km4gijB0f0_@o@Cq-S51&qy@tG&kAj;o1FhW_O|Dab)Tjd-|{Ppo+O&sdsEmid`+Lp z)i<2WPtW*R{OO*spEyrPNirxLp5%)w7$}rYQU`gD6Bs1hxq-g({ss<{WFZco4s~xf z!!(7u-=>^EC6i~c0>h-!ikXQy$ur#{!9nLG4@iw+GY8ud;j6Widrm6Md-Lu|H~(9w zN#{MceNfL;UwA|(+VbjK74xepCr){8RuMTl^ODGGw@ZQt8#vOuteBV&d2UVtI>I10 zh@A--462V09&R{r=A>iOOaY1d-3>tB>q{OJkN|o$!Nw36AVLxXGk(5Jnjg9I?e*Wq s|H_|w@4q)OW^e7IxPLyCalg5DR@}SgcHEE^m^v9eUHx3vIVCg!0NAx5e*gdg literal 0 HcmV?d00001 diff --git a/tests/ref/render/tiling-angle-with-relative-offset-and-spacing.png b/tests/ref/render/tiling-angle-with-relative-offset-and-spacing.png new file mode 100644 index 0000000000000000000000000000000000000000..a747a025f6c6c4d725a8b19bb0b8d7c6325e7146 GIT binary patch literal 684 zcmV;d0#p5oP)^I&FV{e`xyFZU6uRMoC0LRCwC$+RJW(Fcd{m zxOVX4|3A1@+w?TL95xaiWWXNUI7j*1x0lBC! zLaVj~mFviytT4g`DvYqf3L|X9JgaO5VWY)3sf=O}HX7|@tymqxMh8VVWFM(#3{Ye^ zB-j8&hC^lq8KB7U$PxhtDKb1F#Tu+UXTl&-jDd=bHVh)g7^ukT4v}IERAg90iZMu$ z;Snju07b?DBE=Y>$T&cx7y}d;M~D<-kRpST<&h0mWO$@3i!uf)0SKLU&nq6;<^4Jm-gKqVVe0Fhx7Fr;`y zhEd3n;t?4}0Yi#MLQ9N7h7^zVP6`=P9MU=~WJvJ{D`ZIV2rFPnDGp)fAp?}nBCN=O z9D<4rZU6!b8jP?~<2u4h#3kgq!U!)puWXkQR_I}5gOxD>L4%bs-ES8feD`H62I2EA zDl!~G2Jd7U7D0m)86IJS6na**Z=?jH#9WV*Vi+Y++?uI zWw6R&kTqwJHDlnHVc?f$;NW9mVfX(!J)41n$<(9+posJG!Qr zadJs#OUsfiU${K=6q>{ZY+8HSH+Bi4=LEUeO-wq#)`olFbvHrJ7 zzG2S2_qlhUOkJMA_wU;8?eTJdTw4+8;Cwx&j)2C(o9OQnM)0`XTIvy6UM&8ae<|EcKG9qFm<_I_@Y z-d(tq6gy3A<2ub_yLh6K4zclWG~oo=*EDg40bA2Vho;5^6`&~+oIra#6eKv@m=5v? z_OO721$bDLf%bx=KDv5ObTAO`Q4#QQoYd~ZbkJcAixAHdPlbbj+LoxTURnNgwY;}q z(E8ep<3gwVO5ge(15%vPvnD4f)GkZT7nkq|l8`>kGvU{%kAY2%5mHZfvoITGc*t|I z1Ba;MG>DH8UNd$v5aIv{8wkLC zz4^)iwA$R_^4x-G|9Ueqxjg;seS1FLtM{pl{?2jR!ftL9x8g-$qGRxM^>bP0l+XkK DoAUdx literal 0 HcmV?d00001 diff --git a/tests/ref/render/tiling-text-offset-angle.png b/tests/ref/render/tiling-text-offset-angle.png new file mode 100644 index 0000000000000000000000000000000000000000..4c215945fcf6c256908fd6c9b561090f98e88b75 GIT binary patch literal 13278 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 j2+OLYJ?d%-I Date: Fri, 24 Jul 2026 08:20:38 +0200 Subject: [PATCH 029/105] Expand Croatian translations (#8640) --- crates/typst-library/translations/hr.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/translations/hr.txt b/crates/typst-library/translations/hr.txt index e84ad82d2a40..260cacbd56eb 100644 --- a/crates/typst-library/translations/hr.txt +++ b/crates/typst-library/translations/hr.txt @@ -6,6 +6,6 @@ heading = Odjeljak outline = Sadržaj raw = Kôd page = str. -# footnote = -# email = -# telephone = +footnote = Fusnota +email = E-pošta +telephone = Telefon From 4b5f01d5ddf76148dc1703e6d21476a254f2cb4d Mon Sep 17 00:00:00 2001 From: Jeppe Klitgaard Date: Mon, 27 Jul 2026 18:22:10 +0200 Subject: [PATCH 030/105] Update docs folder description to Typst (#8687) --- docs/dev/architecture.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index 9ca2363a0473..6331285da32e 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -27,9 +27,8 @@ Let's start with a broad overview of the directories in this repository: - `crates/typst-timing`: Performance timing for Typst. - `crates/typst-utils`: Utilities for Typst. - `docs`: Generates the content of the official - [documentation][docs] from markdown files and the inline - Rust documentation. Only generates the content and structure, not the concrete - HTML (that part is currently closed source). + [documentation][docs] from Typst files and the inline + Rust documentation. - `tests`: Integration tests for Typst compilation. - `tools`: Tooling for development. From c0cd7b39eee56bbd6e70175a993f7a7aff14b7a2 Mon Sep 17 00:00:00 2001 From: Igor Khanin Date: Mon, 27 Jul 2026 19:24:45 +0300 Subject: [PATCH 031/105] Fix completion of general `path` parameters (#8680) --- crates/typst-ide/src/complete.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 7c0eb2da8690..ce1fc680d2db 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -5,8 +5,8 @@ use ecow::{EcoString, eco_format}; use rustc_hash::FxHashSet; use serde::{Deserialize, Serialize}; use typst::foundations::{ - AsOutput, AutoValue, CastInfo, Func, Label, NativeElement, NoneValue, Output, - ParamInfo, Repr, StyleChain, Styles, Type, Value, fields_on, repr, + AsOutput, AutoValue, CastInfo, Func, Label, NoneValue, Output, ParamInfo, Repr, + StyleChain, Styles, Type, Value, fields_on, repr, }; use typst::layout::{Alignment, Dir}; use typst::syntax::ast::AstNode; @@ -617,8 +617,7 @@ fn path_completion(func: &Func, param: &ParamInfo) -> Option<&'static [&'static (Some("cite"), "style") => &["csl"], (Some("raw"), "syntaxes") => &["sublime-syntax"], (Some("raw"), "theme") => &["tmtheme"], - (Some("attach"), "path") if *func == typst::pdf::AttachElem::ELEM => &[], - (None, "path") => &[], + (_, "path") => &[], _ => return None, }) } @@ -1872,6 +1871,7 @@ mod tests { .with_source("content/c.typ", "#include \"\"") .with_source("content/d.typ", "#pdf.attach(\"\")") .with_source("content/e.typ", "#math.attach(\"\")") + .with_source("content/f.typ", "#read(\"\")") .with_asset_at("assets/tiger.jpg", "tiger.jpg") .with_asset_at("assets/rhino.png", "rhino.png") .with_asset_at("data/example.csv", "example.csv"); @@ -1893,7 +1893,15 @@ mod tests { test(&world, ("content/d.typ", -2)) .must_include([q!("../assets/tiger.jpg"), q!("../data/example.csv")]); - test(&world, ("content/e.typ", -2)).must_exclude([q!("data/example.csv")]); + test(&world, ("content/e.typ", -2)).must_exclude([q!("../data/example.csv")]); + + test(&world, ("content/f.typ", -2)) + .must_include([ + q!("a.typ"), + q!("../assets/tiger.jpg"), + q!("../data/example.csv"), + ]) + .must_exclude([q!("f.typ")]); } #[test] From bc8de171e7a3f0dabc211ac36f76568ad804d53a Mon Sep 17 00:00:00 2001 From: Jeppe Klitgaard Date: Mon, 27 Jul 2026 18:48:26 +0200 Subject: [PATCH 032/105] Example with show rule and capture group for `regex` docs (#8686) Co-authored-by: Laurenz --- crates/typst-library/src/foundations/str.rs | 41 +++++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/crates/typst-library/src/foundations/str.rs b/crates/typst-library/src/foundations/str.rs index 27f427b59d1c..d9f5ef89bded 100644 --- a/crates/typst-library/src/foundations/str.rs +++ b/crates/typst-library/src/foundations/str.rs @@ -969,22 +969,49 @@ fn string_is_empty() -> EcoString { /// A regular expression. /// -/// Can be used as a @reference:styling:show-rules[show rule selector] and with -/// @str[string methods] like `find`, `split`, `replace`, and `match`. +/// Can be used as a @reference:styling:show-rules[show rule selector] or with +/// @str[string methods]. /// -/// #link("https://docs.rs/regex/latest/regex/#syntax")[See here] for a -/// specification of the supported syntax. +/// Visit #link("https://docs.rs/regex/latest/regex/#syntax")[this website] for +/// a complete specification of the supported syntax. +/// +/// = With string methods +/// Regular expressions can be used with string methods like @str.find[`find`], +/// @str.split[`split`], @str.replace[`replace`], @str.match[`match`], or +/// @str.matches[`matches`]. See the documentation of these methods for more +/// details. /// -/// = Example /// ```example -/// // Works with string methods. /// #"a,b;c".split(regex("[,;]")) +/// ``` /// -/// // Works with show rules. +/// = With show rules +/// Regular expressions can also be used with +/// @reference:styling:show-rules[show rules] to match on and transform text in +/// the document. For example, below, we are turning all numbers red. +/// +/// ```example /// #show regex("\\d+"): set text(red) /// /// The numbers 1 to 10. /// ``` +/// +/// Sometimes, you may also want to combine both uses, by first matching on text +/// with a show rule and then rematching on the text to extract a specific +/// #link("https://docs.rs/regex/latest/regex/#grouping-and-flags")[capture group]. +/// In this case, it can be convenient to store the regular expression in a +/// variable instead of repeating it twice. +/// +/// ```example +/// #let pattern = regex("\|([^|]*)\|") +/// #show pattern: it => { +/// let m = it.text.match(pattern) +/// let inner = m.captures.first() +/// highlight(inner) +/// } +/// +/// A |handy-dandy| highlighter! +/// ``` #[ty(scope, since = "forever")] #[derive(Debug, Clone)] pub struct Regex(regex::Regex); From f6faa1d5d0b5dab984a6f4a0a7eafb983f580f77 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 13 Jul 2026 14:20:02 +0200 Subject: [PATCH 033/105] Draw red and green borders around images in test report --- tests/src/report/report.js | 56 ++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/tests/src/report/report.js b/tests/src/report/report.js index 24486ebe54b7..dc94ce3e5ff5 100644 --- a/tests/src/report/report.js +++ b/tests/src/report/report.js @@ -75,7 +75,7 @@ for (const report of document.getElementsByClassName("test-report")) { const expanded = !(reportSourceToggle.ariaExpanded == "true"); reportSource.hidden = !expanded; reportSourceToggle.ariaExpanded = expanded; - + if (expanded) { activeTestSources += 1; } else { @@ -84,7 +84,7 @@ for (const report of document.getElementsByClassName("test-report")) { globalSourceToggle.checked = activeTestSources > 0; }); - + for (const button of reportHeader.querySelectorAll(".copy-button")) { button.addEventListener("click", () => { navigator.clipboard.writeText(button.dataset.filePath); @@ -493,6 +493,7 @@ function disableImageControls(state, mode) { * @property h {number} * @property clip {Path2D?} * @property opacity {number} + * @property border_color {string} */ /** @@ -528,6 +529,7 @@ function redrawImageDiff(state) { w: scale * state.images[0].naturalWidth, h: scale * state.images[0].naturalHeight, opacity: 1, + border_color: "#FF0000", }; /** @type {ImageParams} */ const b = { @@ -536,18 +538,20 @@ function redrawImageDiff(state) { w: scale * state.images[1].naturalWidth, h: scale * state.images[1].naturalHeight, opacity: 1, + border_color: "#00e030", }; const maxWidth = Math.max(a.w, b.w); const maxHeight = Math.max(a.h, b.h); - const sideBySideGap = 1; + const sideBySideGap = 2; - const swipeYPadding = scale * 8; - const swipeXPadding = 2; + const swipeMargin = { x: 2, y: scale * 8}; const swipeDividerWidth = 1; + // Logical size of the canvas without the margin. let canvasSize = { w: maxWidth, h: maxHeight }; + let canvasMargin = { x: 2, y: 2 }; let compositeMode; let swipeDividerPos = null; switch (mode) { @@ -556,7 +560,7 @@ function redrawImageDiff(state) { canvasSize = { w: 2 * maxWidth + sideBySideGap, h: maxHeight }; - // Center align images + // Center align images. a.x = maxWidth - a.w; b.x = maxWidth + sideBySideGap; @@ -568,23 +572,25 @@ function redrawImageDiff(state) { case "swipe": { compositeMode = "source-over"; - const logicalCanvasSize = canvasSize; - canvasSize = { w: maxWidth + 2 * swipeXPadding, h: maxHeight + 2 * swipeYPadding }; + canvasMargin = swipeMargin; - a.x = horizontalAlignImage(a, logicalCanvasSize, alignX) + swipeXPadding; - b.x = horizontalAlignImage(b, logicalCanvasSize, alignX) + swipeXPadding; - a.y = verticalAlignImage(a, logicalCanvasSize, alignY) + swipeYPadding; - b.y = verticalAlignImage(b, logicalCanvasSize, alignY) + swipeYPadding; + a.x = horizontalAlignImage(a, canvasSize, alignX) + b.x = horizontalAlignImage(b, canvasSize, alignX) + a.y = verticalAlignImage(a, canvasSize, alignY) + b.y = verticalAlignImage(b, canvasSize, alignY) - swipeDividerPos = Math.round(blend * (canvasSize.w - swipeDividerWidth)); + swipeDividerPos = { + x: Math.round(blend * canvasSize.w), + y: -swipeMargin.y, + }; // Use clip paths instead of the `drawImage` source paramters to avoid // wobble of the images when moving the slider. a.clip = new Path2D(); - a.clip.rect(0, 0, swipeDividerPos, canvasSize.h); + a.clip.rect(0, 0, swipeDividerPos.x, canvasSize.h); b.clip = new Path2D(); - b.clip.rect(swipeDividerPos, 0, canvasSize.w - swipeDividerPos, canvasSize.h); + b.clip.rect(swipeDividerPos.x, 0, canvasSize.w - swipeDividerPos.x, canvasSize.h); break; } @@ -624,14 +630,15 @@ function redrawImageDiff(state) { } // Computation is done, do the actual drawing. - state.imageCanvas.width = canvasSize.w; - state.imageCanvas.height = canvasSize.h; + state.imageCanvas.width = canvasSize.w + 2 * canvasMargin.x; + state.imageCanvas.height = canvasSize.h + 2 * canvasMargin.y; const ctx = state.imageCanvas.getContext("2d") ctx.clearRect(0, 0, state.imageCanvas.width, state.imageCanvas.height); ctx.imageSmoothingEnabled = antialiased; ctx.globalCompositeOperation = compositeMode; + ctx.translate(canvasMargin.x, canvasMargin.y); drawImage(ctx, state.images[0], a); drawImage(ctx, state.images[1], b); @@ -639,11 +646,10 @@ function redrawImageDiff(state) { // Divider. if (swipeDividerPos != null) { ctx.lineWidth = swipeDividerWidth; - ctx.strokeStyle = "red"; + ctx.strokeStyle = "#007BFF"; const divider = new Path2D(); - console.log(swipeDividerPos); - divider.moveTo(swipeDividerPos, 0); - divider.lineTo(swipeDividerPos, canvasSize.h); + divider.moveTo(swipeDividerPos.x, swipeDividerPos.y); + divider.lineTo(swipeDividerPos.x, state.imageCanvas.height); ctx.stroke(divider); } } @@ -656,8 +662,16 @@ function redrawImageDiff(state) { function drawImage(ctx, img, p) { ctx.save(); ctx.globalAlpha = p.opacity; + + // Draw image. if (p.clip != null) ctx.clip(p.clip); ctx.drawImage(img, p.x, p.y, p.w, p.h); + + // Draw outline. + ctx.lineWidth = 2; + ctx.strokeStyle = p.border_color; + ctx.strokeRect(p.x, p.y, p.w, p.h); + ctx.restore(); } From 111e584a06230b9cf084abb091a4b228e1323364 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 14 Jul 2026 17:10:17 +0200 Subject: [PATCH 034/105] Share image diff controls for each test report --- tests/src/report/diff.rs | 29 ++ tests/src/report/html.rs | 315 ++++++++++---------- tests/src/report/report.css | 135 +++++---- tests/src/report/report.js | 571 ++++++++++++++++++++++-------------- 4 files changed, 609 insertions(+), 441 deletions(-) diff --git a/tests/src/report/diff.rs b/tests/src/report/diff.rs index 130ca545c587..86b571cbab8b 100644 --- a/tests/src/report/diff.rs +++ b/tests/src/report/diff.rs @@ -57,12 +57,41 @@ impl Diff { } } + pub fn kind(&self) -> DiffKind { + match self { + Self::Text(_) => DiffKind::Text, + Self::Image(_) => DiffKind::Image, + Self::Html(_) => DiffKind::Html, + } + } + pub fn mode(&self) -> DiffMode { match self { Diff::Text(_) => DiffMode::Text, Diff::Image(_) | Diff::Html(_) => DiffMode::Visual, } } + + pub fn is_image(&self) -> bool { + matches!(self, Self::Image(..)) + } +} + +#[derive(Copy, Clone)] +pub enum DiffKind { + Text, + Image, + Html, +} + +impl Display for DiffKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + DiffKind::Text => "text", + DiffKind::Image => "image", + DiffKind::Html => "html", + }) + } } #[derive(Copy, Clone)] diff --git a/tests/src/report/html.rs b/tests/src/report/html.rs index 7d057a116247..11c809b6e4df 100644 --- a/tests/src/report/html.rs +++ b/tests/src/report/html.rs @@ -491,9 +491,13 @@ fn test_reports(body: &mut HtmlElem, reports: &[TestReport]) { test_report_header(div, test_report, test_idx, close); }); + let image_kind = match &test_report.files[0].diffs[0] { + Diff::Image(_) => " image", + Diff::Text(_) | Diff::Html(_) => "", + }; div.div() .id(display!("test-report-body-{test_idx}")) - .class("test-report-body") + .class(display!("test-report-body{image_kind}")) .hidden(close) .with(|div| { test_report_source(div, test_report, test_idx); @@ -509,6 +513,15 @@ fn test_reports(body: &mut HtmlElem, reports: &[TestReport]) { file_idx, ); } + + // There is one set of image controls for the + // image diffs of all outputs of a test report. + let has_image_diff = (test_report.files.iter()) + .flat_map(|f| f.diffs.iter()) + .any(Diff::is_image); + if has_image_diff { + image_diff_controls(div, test_idx); + } }); }); } @@ -778,7 +791,7 @@ fn test_report_source(parent: &mut HtmlElem, test_report: &TestReport, test_idx: .hidden(true) .id(display!("test-report-source-{test_idx}")) .with(|div| { - div.table().class("text-diff").with(|table| { + div.table().class("file-diff html").with(|table| { table.colgroup().with(|colgroup| { colgroup.col().attr("span", 1).class("col-line-gutter"); colgroup.col().attr("span", 1).class("col-source-line-body"); @@ -983,11 +996,12 @@ fn file_diff_tabpanel( .role("tabpanel") .aria_labelledby(display!("file-diff-tab-{n}-{}", diff.mode())) .hidden(diff_idx != 0) - .class("file-diff") + .class(display!("file-diff {}", diff.kind())) + .data_attr("kind", diff.kind()) .with(|div| match diff { Diff::Text(diff) => text_diff(div, diff), Diff::Image(diff) => { - image_diff(div, &test_report.name, report_file.output, diff, n); + image_diff(div, &test_report.name, report_file.output, diff); } Diff::Html(diff) => html_diff(div, diff), }); @@ -1072,8 +1086,34 @@ fn image_diff( name: &str, output: TestOutput, diff: &FileDiff, - n: usize, ) { + let image = |parent: &mut HtmlElem<'_>, data_url: &str| { + parent + .img() + .src(data_url) + .alt(display!("The {output} image of `{name}` test")); + }; + + parent + .canvas() + .class("image-canvas") + .data_attr("output", output) + .with(|canvas| { + let data_url = (diff.left()) + .and_then(|old| old.data()) + .map(|img| img.data_url.as_str()) + .unwrap_or(""); + image(canvas, data_url); + + let data_url = (diff.right()) + .and_then(|res| res.as_ref().ok()) + .map(|img| img.data_url.as_str()) + .unwrap_or(""); + image(canvas, data_url); + }); +} + +fn image_diff_controls(parent: &mut HtmlElem, n: usize) { let radio_icon_button = |parent: &mut HtmlElem, name, value, title, icon, checked| { parent.label().class("icon-toggle-button").with(|label| { label @@ -1148,165 +1188,136 @@ fn image_diff( }); }; - parent.div().class("image-diff").with(|div| { - div.div().class("image-controls").with(|div| { - div.fieldset().class("control-group").with(|fieldset| { + parent.div().class("image-controls top").with(|div| { + div.fieldset().class("control-group").with(|fieldset| { + radio_icon_button( + fieldset, + "image-view-mode", + "side-by-side", + "View Mode side by side", + icons::VIEW_SIDE_BY_SIDE, + true, + ); + radio_icon_button( + fieldset, + "image-view-mode", + "swipe", + "View Mode swipe", + icons::VIEW_SWIPE, + false, + ); + radio_icon_button( + fieldset, + "image-view-mode", + "blend", + "View Mode blend", + icons::VIEW_BLEND, + false, + ); + radio_icon_button( + fieldset, + "image-view-mode", + "difference", + "View Mode difference", + icons::VIEW_DIFFERENCE, + false, + ); + }); + + div.fieldset().class("control-group").with(|fieldset| { + checkbox_icon_button( + fieldset, + "image-antialiasing", + "Antialiasing", + icons::ANTIALIASING, + true, + ); + }); + + div.fieldset().class("control-group").with(|fieldset| { + icon_button(fieldset, "image-zoom-minus", "Zoom out", icons::MINUS); + icon_button(fieldset, "image-zoom-plus", "Zoom in", icons::PLUS); + + slider( + fieldset, + "image-zoom", + "Zoom", + None, + SliderOpts { min: 0.5, max: 8.0, value: 2.0, step: 0.05 }, + ); + }); + }); + + parent.div().class("image-controls bottom").with(|div| { + div.fieldset() + .class("control-group image-align-y-control") + .with(|fieldset| { radio_icon_button( fieldset, - "image-view-mode", - "side-by-side", - "View Mode side by side", - icons::VIEW_SIDE_BY_SIDE, + "image-align-y", + "top", + "Vertical-align top", + icons::ALIGN_TOP, true, ); radio_icon_button( fieldset, - "image-view-mode", - "swipe", - "View Mode swipe", - icons::VIEW_SWIPE, + "image-align-y", + "center", + "Vertical-align center", + icons::ALIGN_HORIZON, false, ); radio_icon_button( fieldset, - "image-view-mode", - "blend", - "View Mode blend", - icons::VIEW_BLEND, + "image-align-y", + "bottom", + "Vertical-align bottom", + icons::ALIGN_BOTTOM, false, ); + }); + + div.fieldset() + .class("control-group image-align-x-control") + .with(|fieldset| { + radio_icon_button( + fieldset, + "image-align-x", + "left", + "Horizontal-align left", + icons::ALIGN_LEFT, + true, + ); radio_icon_button( fieldset, - "image-view-mode", - "difference", - "View Mode difference", - icons::VIEW_DIFFERENCE, + "image-align-x", + "center", + "Horizontal-align center", + icons::ALIGN_CENTER, false, ); - }); - - div.fieldset().class("control-group").with(|fieldset| { - checkbox_icon_button( + radio_icon_button( fieldset, - "image-antialiasing", - "Antialiasing", - icons::ANTIALIASING, - true, + "image-align-x", + "right", + "Horizontal-align right", + icons::ALIGN_RIGHT, + false, ); }); - div.fieldset().class("control-group").with(|fieldset| { - icon_button(fieldset, "image-zoom-minus", "Zoom out", icons::MINUS); - icon_button(fieldset, "image-zoom-plus", "Zoom in", icons::PLUS); - - // HACK: Scale factor of HTML pt (`1/72 inch`) to px (`1/96 inch`). - // Since PNG images are rendered with 1 px/pt and PDFs converted - // to SVGs don't currently specify a unit thus default to px. - let factor = if output == TestOutput::Svg { 72.0 / 96.0 } else { 1.0 }; + div.fieldset() + .class("control-group image-blend-control") + .with(|fieldset| { slider( fieldset, - "image-zoom", - "Zoom", - None, - factor * SliderOpts { min: 0.5, max: 8.0, value: 2.0, step: 0.05 }, + "image-blend", + "Blend", + Some(icons::VIEW_BLEND), + SliderOpts { min: 0.0, max: 1.0, value: 0.5, step: 0.01 }, ); }); - }); - - div.div().class("image-diff-wrapper").with(|div| { - let image = |parent: &mut HtmlElem<'_>, data_url: &str| { - parent - .img() - .src(data_url) - .alt(display!("The {output} image of `{name}` test")); - }; - - div.canvas().class("image-canvas").with(|canvas| { - let data_url = (diff.left()) - .and_then(|old| old.data()) - .map(|img| img.data_url.as_str()) - .unwrap_or(""); - image(canvas, data_url); - - let data_url = (diff.right()) - .and_then(|res| res.as_ref().ok()) - .map(|img| img.data_url.as_str()) - .unwrap_or(""); - image(canvas, data_url); - }); - }); - - div.div().class("image-mode-controls").with(|div| { - div.fieldset().class("control-group image-align-y-control").with( - |fieldset| { - radio_icon_button( - fieldset, - "image-align-y", - "top", - "Vertical-align top", - icons::ALIGN_TOP, - true, - ); - radio_icon_button( - fieldset, - "image-align-y", - "center", - "Vertical-align center", - icons::ALIGN_HORIZON, - false, - ); - radio_icon_button( - fieldset, - "image-align-y", - "bottom", - "Vertical-align bottom", - icons::ALIGN_BOTTOM, - false, - ); - }, - ); - - div.fieldset().class("control-group image-align-x-control").with( - |fieldset| { - radio_icon_button( - fieldset, - "image-align-x", - "left", - "Horizontal-align left", - icons::ALIGN_LEFT, - true, - ); - radio_icon_button( - fieldset, - "image-align-x", - "center", - "Horizontal-align center", - icons::ALIGN_CENTER, - false, - ); - radio_icon_button( - fieldset, - "image-align-x", - "right", - "Horizontal-align right", - icons::ALIGN_RIGHT, - false, - ); - }, - ); - - div.fieldset() - .class("control-group image-blend-control") - .with(|fieldset| { - slider( - fieldset, - "image-blend", - "Blend", - Some(icons::VIEW_BLEND), - SliderOpts { min: 0.0, max: 1.0, value: 0.5, step: 0.01 }, - ); - }); - }); }); } @@ -1320,19 +1331,17 @@ fn html_diff(parent: &mut HtmlElem, diff: &FileDiff) { .opt_attr("style", data_url.is_empty().then_some("visibility: hidden")); }; - parent.div().class("html-diff").with(|div| { - let data_url = (diff.left()) - .and_then(|old| old.data()) - .map(|html| html.data_url.as_str()) - .unwrap_or(""); - iframe(div, data_url); - - let data_url = (diff.right()) - .and_then(|res| res.as_ref().ok()) - .map(|html| html.data_url.as_str()) - .unwrap_or(""); - iframe(div, data_url); - }); + let data_url = (diff.left()) + .and_then(|old| old.data()) + .map(|html| html.data_url.as_str()) + .unwrap_or(""); + iframe(parent, data_url); + + let data_url = (diff.right()) + .and_then(|res| res.as_ref().ok()) + .map(|html| html.data_url.as_str()) + .unwrap_or(""); + iframe(parent, data_url); } #[rustfmt::skip] diff --git a/tests/src/report/report.css b/tests/src/report/report.css index bd5704b734dc..330a1e0d0cbb 100644 --- a/tests/src/report/report.css +++ b/tests/src/report/report.css @@ -224,6 +224,11 @@ h2.diff-container-header { } } +.test-report-body { + position: relative; + width: 100%; +} + .test-report-source { border-bottom: 1.25rem solid var(--layer-2); } @@ -338,11 +343,74 @@ a.report-file-link:active { } .report-file { + width: 100%; + height: 100%; position: relative; } +.file-diff { + width: 100%; + height: 100%; +} + +/* ========== Image diff ========== */ + +.test-report-body > .image-controls { + visibility: hidden; +} + +.test-report-body.image > .image-controls { + visibility: visible; +} + +.test-report-body.image:not(:hover,:has(*:focus-visible)) > .image-controls { + /* Avoid flicker in some cases */ + transition: opacity 0.1s cubic-bezier(0.64, 0, 0.78, 0) 0.1s; + opacity: 0; +} + +.image-controls { + position: absolute; + width: 100%; + margin: var(--size-xxxsmall) 0; + padding: 0 var(--size-xxxsmall); + + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: var(--size-xxxsmall); + + z-index: 100; +} + +.image-controls.top { + top: 0; +} + +.image-controls.bottom { + bottom: 0; +} + +.file-diff.image { + width: 100%; + height: 400px; + min-height: 100px; + max-height: 100vh; + resize: vertical; + overflow: hidden; + + display: flex; + justify-content: center; + align-items: center; +} + /* ========== Text diff ========== */ +.file-diff.text { + width: 100%; +} + table.text-diff { width: 100%; border-style: none; @@ -443,74 +511,11 @@ tr { margin: var(--line-margin) 0; } -/* ========== Image diff ========== */ - -.image-diff { - position: relative; - height: 400px; - width: 100%; - min-height: 100px; - max-height: 100vh; - resize: vertical; - overflow: hidden; - - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; -} - -/* Only show image controls on hover */ -.image-diff:not(:hover,:has(*:focus-visible)) { - > .image-controls, - > .image-mode-controls { - /* Avoid flicker in some cases */ - transition: opacity 0.1s cubic-bezier(0.64, 0, 0.78, 0) 0.1s; - opacity: 0; - } -} - -.image-controls { - position: absolute; - top: 0; - width: 100%; - padding: var(--size-xxxsmall); - - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - gap: var(--size-xxxsmall); - - z-index: 100; -} - -.image-mode-controls { - position: absolute; - bottom: 0; - width: 100%; - padding: var(--size-xxxsmall); - - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - gap: var(--size-xxxsmall); - - z-index: 100; -} - -.image-diff-wrapper { - display: flex; - justify-content: center; - align-items: center; -} - /* ========== HTML diff ========== */ -.html-diff { - height: 400px; +.file-diff.html { width: 100%; + height: 400px; min-height: 100px; max-height: 100vh; resize: vertical; diff --git a/tests/src/report/report.js b/tests/src/report/report.js index dc94ce3e5ff5..89a4aa31d05f 100644 --- a/tests/src/report/report.js +++ b/tests/src/report/report.js @@ -5,8 +5,8 @@ const sidebarLinks = sidebarList.querySelectorAll("a") /** @type {TestReportState[]} */ const testReports = [] -/** @type {ReportFileState[]} */ -const reportFiles = [] +/** @type {ImageDiffState} */ +const imageDiffs = [] /** * @typedef TestReportState @@ -14,56 +14,103 @@ const reportFiles = [] * @property report {HTMLDetailsElement} * @property reportToggle {HTMLButtonElement} * @property reportSourceToggle {HTMLButtonElement} - * @property reportFileHeaders {NodeListOf} - * @property reportFileTabs {NodeListOf} - * @property reportBody HTMLDivElement - * @property reportSource HTMLDivElement - * @property reportFileTabpanels {NodeListOf} + * @property reportBody {HTMLDivElement} + * @property reportSource {HTMLDivElement} + * @property files {ReportFileState[]} */ /** - * @typedef {"render" | "pdf" | "pdftags" | "svg" | "html"} TestOutput + * @typedef ReportFileState + * @type {object} + * @property report {TestReportState} + * @property header {HTMLDivElement} + * @property tab {HTMLInputElement} + * @property tabpanel {HTMLElement} + * @property diffs {FileDiffState[]} */ /** - * @typedef ReportFileState + * @typedef FileDiffState * @type {object} - * @property fileDiffTabs {NodeListOf} - * @property fileDiffTabpanels {NodeListOf} + * @property file {ReportFileState} + * @property kind {DiffKind} + * @property tab {HTMLInputElement?} + * @property tabpanel {HTMLElement} + */ + +/** + * @typedef {"render" | "pdf" | "pdftags" | "svg" | "html"} TestOutput */ /** * @typedef {"visual" | "text"} DiffMode */ +/** + * @typedef {"text" | "image" | "html"} DiffKind + */ + +/** + * @typedef ImageDiffState + * @type {object} + * @property imageModes {HTMLInputElement[]} + * @property imageAntialiasing {HTMLInputElement} + * @property imageZoom {HTMLInputElement} + * @property imageAlignXControl {HTMLElement} + * @property imageAlignY {HTMLInputElement[]} + * @property imageAlignX {HTMLInputElement[]} + * @property imageBlendControl {HTMLElement} + * @property imageBlend {HTMLInputElement} + * @property canvases {ImageDiffCanvas[]} + */ + + +/** + * @typedef ImageDiffCanvas + * @type {object} + * @property output {TestOutput} + * @property visible {bool} Whether the canvas is visible. + * @property imagesDecoded {bool} Whether the images have been decoded and their + * natural dimensions are known. + * @property state {CanvasDrawState} Whether the canvas should be (re-)drawn. + * @property imageCanvas {HTMLCanvasElement} + * @property images {HTMLImageElement[]} + */ + +/** + * @typedef {"side-by-side" | "swipe" | "blend" | "difference"} ImageViewMode + */ + +/** + * @typedef {"hidden" | "dirty" | "up-to-date"} CanvasDrawState + */ + let activeTestSources = 0 // Avoid implicit statefulness by the browser globalSourceToggle.checked = false -for (const report of document.getElementsByClassName("test-report")) { - const reportHeader = report.querySelector(".test-report-header") +for (const testReport of document.getElementsByClassName("test-report")) { + const reportHeader = testReport.querySelector(".test-report-header") const reportToggle = reportHeader.querySelector(".test-report-toggle") const reportSourceToggle = reportHeader.querySelector(".test-report-source-toggle") const reportFileHeaders = reportHeader.querySelectorAll(".report-file-header"); const reportFileTabGroup = reportHeader.querySelector(".report-file-tab-group"); const reportFileTabs = reportFileTabGroup.querySelectorAll(".report-file-tab"); - const reportBody = report.querySelector(".test-report-body") - const reportSource = report.querySelector(".test-report-source") + const reportBody = testReport.querySelector(".test-report-body") + const reportSource = reportBody.querySelector(".test-report-source") const reportFileTabpanels = reportBody.querySelectorAll(":scope > .report-file"); /** @type {TestReportState} */ - const state = { - report, + const report = { + report: testReport, reportBody, - reportFileHeaders, - reportFileTabs, - reportFileTabpanels, reportToggle, reportSourceToggle, reportSource, + files: [], } - testReports.push(state); + testReports.push(report); reportToggle.addEventListener("click", () => { const expanded = !(reportToggle.ariaExpanded == "true"); @@ -93,38 +140,177 @@ for (const report of document.getElementsByClassName("test-report")) { }) } - for (const tab of reportFileTabs) { + for (const [idx, tab] of reportFileTabs.entries()) { + /** @type {ReportFileState} */ + const file = { + report, + tab, + header: reportFileHeaders[idx], + tabpanel: reportFileTabpanels[idx], + diffs: [], + }; + + report.files.push(file) + tab.addEventListener("change", (e) => { - reportFileTabChanged(state, e.target.value) + reportFileTabChanged(report, e.target.value, true) }) } - reportFileTabChanged(state, currentReportFileTab(state)) - - for (const [idx, child] of reportFileTabGroup.childNodes.entries()) { - const fileDiffTabGroup = child.querySelector(".file-diff-tab-group"); + reportFileTabChanged(report, currentReportFileTab(report), false) + + + /** @type {FileDiffState} */ + const reportImageDiffs = []; + // Bind the height of the image diffs in a report. + const resizeObserver = new ResizeObserver(entries => { + const entry = entries[entries.length - 1]; + const newHeight = entry.target.style.height || "400px"; + for (const diff of reportImageDiffs) { + if (diff.tabpanel != entry.target) { + diff.tabpanel.style.height = newHeight; + } + } + }); + for (const file of report.files) { // Not all file reports have multiple diffs. File diff tabs are only // generated when necessary. - if (fileDiffTabGroup == null) { - continue; + const tabWrapper = file.tab.parentElement.parentElement; + let fileDiffTabs = [] + if (tabWrapper.classList.contains("report-file-tab-wrapper")) { + fileDiffTabs = tabWrapper.querySelectorAll(".file-diff-tab"); } - const fileDiffTabs = fileDiffTabGroup.querySelectorAll(".file-diff-tab"); - const fileDiffTabpanels = reportFileTabpanels[idx].querySelectorAll(":scope > .file-diff"); + const fileDiffTabpanels = file.tabpanel.querySelectorAll(":scope > .file-diff"); + for (const [idx, tabpanel] of fileDiffTabpanels.entries()) { + const tab = fileDiffTabs[idx] || null; + const kind = tabpanel.dataset.kind; + /** @type {FileDiffState} */ + const diff = { + file, + kind, + tab, + tabpanel, + }; - /** @type {ReportFileState} */ - const state = { - fileDiffTabs, - fileDiffTabpanels, + if (kind == "image") { + resizeObserver.observe(diff.tabpanel); + reportImageDiffs.push(diff); + } + + file.diffs.push(diff); + + if (tab != null) { + tab.addEventListener("change", (e) => { + fileDiffTabChanged(file, e.target.value, true) + }) + } } - reportFiles.push(state); - for (const tab of fileDiffTabs) { - tab.addEventListener("change", (e) => { - fileDiffTabChanged(state, e.target.value) - }) + fileDiffTabChanged(file, currentFileDiffTab(file), false); + } + + // There is one set of image controls for the image diffs of all output + // formats inside a test report. This makes comparing the different formats + // more convenient. + const imageControlsTop = reportBody.querySelector(":scope > .image-controls.top") + const imageControlsBottom = reportBody.querySelector(":scope > .image-controls.bottom") + if (imageControlsTop != null && imageControlsBottom != null) { + const imageModes = imageControlsTop.querySelectorAll("input.image-view-mode") + const imageAntialiasing = imageControlsTop.querySelector("input.image-antialiasing") + const imageZoom = imageControlsTop.querySelector("input.image-zoom") + const imageZoomPlus = imageControlsTop.querySelector("button.image-zoom-plus") + const imageZoomMinus = imageControlsTop.querySelector("button.image-zoom-minus") + + const imageAlignXControl = imageControlsBottom.querySelector(".image-align-x-control") + const imageAlignX = imageAlignXControl.querySelectorAll(".image-align-x") + const imageAlignYControl = imageControlsBottom.querySelector(".image-align-y-control") + const imageAlignY = imageAlignYControl.querySelectorAll(".image-align-y") + const imageBlendControl = imageControlsBottom.querySelector(".image-blend-control") + const imageBlend = imageControlsBottom.querySelector("input.image-blend") + + /** @type {ImageDiffState} */ + const imageState = { + imageModes, + imageAntialiasing, + imageZoom, + imageAlignXControl, + imageAlignX, + imageAlignY, + imageBlendControl, + imageBlend, + canvases: [], + }; + imageDiffs.push(imageState); + + for (const imageMode of imageModes) { + imageMode.addEventListener("change", (e) => { + imageModeChanged(imageState, e.target.value); + }); + } + + imageAntialiasing.addEventListener("change", () => imageDiffChanged(imageState)); + + imageZoom.addEventListener("change", () => imageDiffChanged(imageState)); + imageZoom.addEventListener("input", () => imageDiffChanged(imageState)); + + imageZoomMinus.addEventListener("click", () => { + imageZoom.stepDown() + imageDiffChanged(imageState) + }); + imageZoomPlus.addEventListener("click", () => { + imageZoom.stepUp() + imageDiffChanged(imageState) + }); + + for (const align of imageAlignX) { + align.addEventListener("change", () => imageDiffChanged(imageState)); + } + for (const align of imageAlignY) { + align.addEventListener("change", () => imageDiffChanged(imageState)); + } + + imageBlend.addEventListener("change", () => imageDiffChanged(imageState)); + imageBlend.addEventListener("input", () => imageDiffChanged(imageState)); + + // Initially enable/disable the image controls. + disableImageControls(imageState, currentImageMode(imageState)); + + for (const imageDiff of reportBody.querySelectorAll(".file-diff.image")) { + const imageCanvas = imageDiff.querySelector(".image-canvas") + const images = imageCanvas.querySelectorAll("img") + + /** @type {ImageDiffCanvas} */ + const canvasState = { + output: imageCanvas.dataset.output, + visible: false, + imagesDecoded: false, + state: "hidden", + imageCanvas, + images, + }; + + // Issue a lazy canvas redaw when the images have been decoded. + let numDecoded = 0; + for (const img of images) { + // Ignore invalid images. + img.decode().catch(() => { }).then(() => { + numDecoded += 1; + if (numDecoded == images.length) { + canvasState.imagesDecoded = true; + redrawImageDiff(imageState, canvasState); + } + }) + } + + // Issue a lazy canvas redaw if the images become visible on screen. + onViewportIntersectionChanged(imageDiff, (visible) => { + canvasState.visible = visible; + redrawImageDiff(imageState, canvasState); + }); + + imageState.canvases.push(canvasState); } - fileDiffTabChanged(state, currentFileDiffTab(state)) } } @@ -152,8 +338,8 @@ filterSearch.addEventListener("change", () => { filterDiffs() -let diff_modes = ["visual", "text"] -for (const mode of diff_modes) { +const diffModes = ["visual", "text"] +for (const mode of diffModes) { document.getElementById(`global-diff-mode-${mode}`) .addEventListener("click", () => { changeGlobalDiffMode(mode) @@ -185,8 +371,8 @@ function filterDiffs() { // Filter format if (outputs.length > 0 && !filteredOut) { filteredOut = true; - for (const tab of report.reportFileTabs) { - if (outputs.includes(tab.value)) { + for (const file of report.files) { + if (outputs.includes(file.tab.value)) { filteredOut = false; break; } @@ -202,221 +388,139 @@ function filterDiffs() { * @param output {TestOutput} */ function changeGlobalDiffFormat(output) { - for (const state of testReports) { + for (const report of testReports) { let found = false - for (const tab of state.reportFileTabs) { - if (tab.value == output) { - tab.checked = true; + for (const file of report.files) { + if (file.tab.value == output) { + file.tab.checked = true; found = true; break; } } if (found) { - reportFileTabChanged(state, output) + reportFileTabChanged(report, output, true) } } } /** - * @param state {TestReportState} + * @param report {TestReportState} * @param output {TestOutput} + * @param update_child {boolean} */ -function reportFileTabChanged(state, output) { - for (const [idx, tab] of state.reportFileTabs.entries()) { - const selected = tab.value == output; - tab.ariaSelected = selected; - state.reportFileTabpanels[idx].hidden = !selected; - state.reportFileHeaders[idx].hidden = !selected; +function reportFileTabChanged(report, output, update_child) { + for (const file of report.files) { + const selected = file.tab.value == output; + file.tab.ariaSelected = selected; + file.tabpanel.hidden = !selected; + file.header.hidden = !selected; + + // Check which file is visible. + if (update_child && selected) { + fileDiffTabChanged(file, currentFileDiffTab(file), false); + } } } /** - * @param state {TestReportState} + * @param report {TestReportState} * @return {TestOutput} */ -function currentReportFileTab(state) { - for (const tab of state.reportFileTabs) { - if (tab.checked) { - return tab.value +function currentReportFileTab(report) { + for (const file of report.files) { + if (file.tab.checked) { + return file.tab.value } } } /** - * @param diff_mode {DiffMode} + * @param diffMode {DiffMode} */ -function changeGlobalDiffMode(diff_mode) { - for (const state of reportFiles) { - let found = false - for (const tab of state.fileDiffTabs) { - if (tab.value == diff_mode) { - tab.checked = true; - found = true; - break; +function changeGlobalDiffMode(diffMode) { + for (const report of testReports) { + for (const file of report.files) { + let found = false + for (const diff of file.diffs) { + if (diff.tab?.value == diffMode) { + diff.tab.checked = true; + found = true; + break; + } + } + if (found) { + fileDiffTabChanged(file, diffMode, false) } - } - if (found) { - fileDiffTabChanged(state, diff_mode) } } } /** - * @param state {ReportFileState} - * @param diff_mode {DiffMode} + * @param file {ReportFileState} + * @param diffMode {DiffMode?} + * @param update_parent {boolean} */ -function fileDiffTabChanged(state, diff_mode) { - for (const [idx, tab] of state.fileDiffTabs.entries()) { - const selected = tab.value == diff_mode; - tab.ariaSelected = selected; - state.fileDiffTabpanels[idx].hidden = !selected; +function fileDiffTabChanged(file, diffMode, update_parent) { + let kind = null; + if (diffMode == null) { + console.assert(file.diffs.length == 1, "expected exactly one report file diff"); + + kind = file.diffs[0].kind; + } else { + for (const diff of file.diffs) { + const selected = diff.tab.value == diffMode; + diff.tab.ariaSelected = selected; + diff.tabpanel.hidden = !selected; + + if (selected) kind = diff.kind; + } + } + + // When the button of a nested tab is pressed, also update the parent tab. + if (update_parent) { + file.tab.checked = true; + reportFileTabChanged(file.report, file.tab.value, false) + } + + // When this tab is visible update the test report diff kind. + if (file.tab.checked) { + file.report.reportBody.classList.toggle("image", kind == "image") } } /** - * @param state {ReportFileState} - * @return {DiffMode} + * @param file {ReportFileState} + * @return {DiffMode?} */ -function currentFileDiffTab(state) { - for (const tab of state.fileDiffTabs) { - if (tab.checked) { - return tab.value +function currentFileDiffTab(file) { + for (const diff of file.diffs) { + if (diff.tab?.checked) { + return diff.tab.value } } + + // This only happens if there is only one file diff. + return null; } /** * @param visible {boolean} */ function changeGlobalSourceVisibility(visible) { - for (const state of testReports) { - state.reportSource.hidden = !visible; - state.reportSourceToggle.ariaExpanded = visible; + for (const report of testReports) { + report.reportSource.hidden = !visible; + report.reportSourceToggle.ariaExpanded = visible; } activeTestSources = visible ? testReports.length : 0; } -/** @type {ImageDiffState} */ -const imageDiffs = [] - -/** - * @typedef ImageDiffState - * @type {object} - * @property visible {bool} Whether the canvas is visible. - * @property imagesDecoded {bool} Whether the images have been decoded and their - * natural dimensions are known. - * @property dirty {bool} Whether the canvas should be (re-)drawn. - * @property imageCanvas {HTMLCanvasElement} - * @property images {HTMLImageElement[]} - * @property imageModes {HTMLInputElement[]} - * @property imageAntialiasing {HTMLInputElement} - * @property imageZoom {HTMLInputElement} - * @property imageAlignXControl {HTMLElement} - * @property imageAlignY {HTMLInputElement[]} - * @property imageAlignX {HTMLInputElement[]} - * @property imageBlendControl {HTMLElement} - * @property imageBlend {HTMLInputElement} - */ - -/** - * @typedef {"side-by-side" | "swipe" | "blend" | "difference"} ImageViewMode - */ - -for (const imageDiff of document.getElementsByClassName("image-diff")) { - const imageWrapper = imageDiff.querySelector(".image-diff-wrapper") - const imageCanvas = imageWrapper.querySelector(".image-canvas") - const images = imageCanvas.querySelectorAll("img") - - const imageModes = imageDiff.querySelectorAll("input.image-view-mode") - const imageAntialiasing = imageDiff.querySelector("input.image-antialiasing") - const imageZoom = imageDiff.querySelector("input.image-zoom") - const imageZoomPlus = imageDiff.querySelector("button.image-zoom-plus") - const imageZoomMinus = imageDiff.querySelector("button.image-zoom-minus") - const imageAlignXControl = imageDiff.querySelector(".image-align-x-control") - const imageAlignX = imageAlignXControl.querySelectorAll(".image-align-x") - const imageAlignYControl = imageDiff.querySelector(".image-align-y-control") - const imageAlignY = imageAlignYControl.querySelectorAll(".image-align-y") - const imageBlendControl = imageDiff.querySelector(".image-blend-control") - const imageBlend = imageDiff.querySelector("input.image-blend") - - /** @type {ImageDiffState} */ - const state = { - visible: false, - imagesLoaded: false, - dirty: true, - imageCanvas, - images, - imageModes, - imageAntialiasing, - imageZoom, - imageAlignXControl, - imageAlignX, - imageAlignY, - imageBlendControl, - imageBlend, - } - imageDiffs.push(state); - - for (const imageMode of imageModes) { - imageMode.addEventListener("change", (e) => { - imageModeChanged(state, e.target.value); - }); - } - - imageAntialiasing.addEventListener("change", () => imageDiffChanged(state)); - - imageZoom.addEventListener("change", () => imageDiffChanged(state)); - imageZoom.addEventListener("input", () => imageDiffChanged(state)); - - imageZoomMinus.addEventListener("click", () => { - imageZoom.stepDown() - imageDiffChanged(state) - }); - imageZoomPlus.addEventListener("click", () => { - imageZoom.stepUp() - imageDiffChanged(state) - }); - - for (const align of imageAlignX) { - align.addEventListener("change", () => imageDiffChanged(state)); - } - for (const align of imageAlignY) { - align.addEventListener("change", () => imageDiffChanged(state)); - } - - imageBlend.addEventListener("change", () => imageDiffChanged(state)); - imageBlend.addEventListener("input", () => imageDiffChanged(state)); - - // Initially enable/disable the image controls. - disableImageControls(state, currentImageMode(state)); - - // Issue a lazy canvas redaw when the images have been decoded. - let numDecoded = 0; - for (const img of images) { - // Ignore invalid images. - img.decode().catch(() => {}).then(() => { - numDecoded += 1; - if (numDecoded == images.length) { - state.imagesDecoded = true; - redrawImageDiff(state); - } - }) - } - - // Issue a lazy canvas redaw if the images become visible on screen. - onViewportIntersectionChanged(imageWrapper, (visible) => { - state.visible = visible; - redrawImageDiff(state); - }); -} - /** * @param element {HTMLElement} * @param callback {(visible: bool) => void} */ function onViewportIntersectionChanged(element, callback) { - var observer = new IntersectionObserver((entries, _observer) => { + const observer = new IntersectionObserver((entries, _observer) => { for (const entry of entries) { callback(entry.intersectionRatio > 0); } @@ -500,22 +604,42 @@ function disableImageControls(state, mode) { * @param state {ImageDiffState} */ function imageDiffChanged(state) { - state.dirty = true; - redrawImageDiff(state); + for (const canvas of state.canvases) { + // Only mark the canvas as dirty if it was drawn to. + // If it was "hidden" it is still hidden. + if (canvas.state == "up-to-date") { + canvas.state = "dirty"; + } + redrawImageDiff(state, canvas); + } } /** * @param state {ImageDiffState} + * @param canvas {ImageDiffCanvas} */ -function redrawImageDiff(state) { +function redrawImageDiff(state, canvas) { // In large test reports drawing only the image diffs that are on screen is // necessary to create a somewhat usable experience. It also dramatically // reduces loading time. - if (!(state.visible && state.dirty && state.imagesDecoded)) return; + if (!(canvas.visible && canvas.state != "up-to-date" && canvas.imagesDecoded)) { + if (canvas.state == "dirty") { + // Hide the canvas to avoid showing outdated drawn diff, when the canvas + // comes into view. + canvas.imageCanvas.hidden = true; + canvas.state = "hidden"; + } + return; + } - state.dirty = false; + canvas.imageCanvas.hidden = false; + canvas.state = "up-to-date"; - const scale = state.imageZoom.value + // HACK: Scale factor of HTML pt (`1/72 inch`) to px (`1/96 inch`). + // Since PNG images are rendered with 1 px/pt and PDFs converted + // to SVGs don't currently specify a unit thus default to px. + const factor = (canvas.output == "svg") ? (72.0 / 96.0) : 1.0; + const scale = factor * state.imageZoom.value const antialiased = state.imageAntialiasing.checked; const mode = currentImageMode(state) const alignX = currentImageAlignX(state); @@ -526,8 +650,8 @@ function redrawImageDiff(state) { const a = { x: 0, y: 0, - w: scale * state.images[0].naturalWidth, - h: scale * state.images[0].naturalHeight, + w: scale * canvas.images[0].naturalWidth, + h: scale * canvas.images[0].naturalHeight, opacity: 1, border_color: "#FF0000", }; @@ -535,8 +659,8 @@ function redrawImageDiff(state) { const b = { x: 0, y: 0, - w: scale * state.images[1].naturalWidth, - h: scale * state.images[1].naturalHeight, + w: scale * canvas.images[1].naturalWidth, + h: scale * canvas.images[1].naturalHeight, opacity: 1, border_color: "#00e030", }; @@ -546,7 +670,7 @@ function redrawImageDiff(state) { const sideBySideGap = 2; - const swipeMargin = { x: 2, y: scale * 8}; + const swipeMargin = { x: 2, y: scale * 8 }; const swipeDividerWidth = 1; // Logical size of the canvas without the margin. @@ -630,18 +754,18 @@ function redrawImageDiff(state) { } // Computation is done, do the actual drawing. - state.imageCanvas.width = canvasSize.w + 2 * canvasMargin.x; - state.imageCanvas.height = canvasSize.h + 2 * canvasMargin.y; + canvas.imageCanvas.width = canvasSize.w + 2 * canvasMargin.x; + canvas.imageCanvas.height = canvasSize.h + 2 * canvasMargin.y; - const ctx = state.imageCanvas.getContext("2d") - ctx.clearRect(0, 0, state.imageCanvas.width, state.imageCanvas.height); + const ctx = canvas.imageCanvas.getContext("2d") + ctx.clearRect(0, 0, canvas.imageCanvas.width, canvas.imageCanvas.height); ctx.imageSmoothingEnabled = antialiased; ctx.globalCompositeOperation = compositeMode; ctx.translate(canvasMargin.x, canvasMargin.y); - drawImage(ctx, state.images[0], a); - drawImage(ctx, state.images[1], b); + drawImage(ctx, canvas.images[0], a); + drawImage(ctx, canvas.images[1], b); // Divider. if (swipeDividerPos != null) { @@ -649,7 +773,7 @@ function redrawImageDiff(state) { ctx.strokeStyle = "#007BFF"; const divider = new Path2D(); divider.moveTo(swipeDividerPos.x, swipeDividerPos.y); - divider.lineTo(swipeDividerPos.x, state.imageCanvas.height); + divider.lineTo(swipeDividerPos.x, canvas.imageCanvas.height); ctx.stroke(divider); } } @@ -707,6 +831,7 @@ function verticalAlignImage(img, size, align) { /** * @param state {ImageDiffState} + * @returns {ImageViewMode} */ function currentImageMode(state) { for (const imageMode of state.imageModes) { From 0b56cf0b89be349d142fe6e3c20dfb06613271a2 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 15 Jul 2026 17:12:53 +0200 Subject: [PATCH 035/105] Add ctrl+scroll and pinch to zoom for test report image diffs --- tests/src/report/report.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/src/report/report.js b/tests/src/report/report.js index 89a4aa31d05f..41dac8467997 100644 --- a/tests/src/report/report.js +++ b/tests/src/report/report.js @@ -218,6 +218,7 @@ for (const testReport of document.getElementsByClassName("test-report")) { if (imageControlsTop != null && imageControlsBottom != null) { const imageModes = imageControlsTop.querySelectorAll("input.image-view-mode") const imageAntialiasing = imageControlsTop.querySelector("input.image-antialiasing") + /** @type {HTMLInputElement} */ const imageZoom = imageControlsTop.querySelector("input.image-zoom") const imageZoomPlus = imageControlsTop.querySelector("button.image-zoom-plus") const imageZoomMinus = imageControlsTop.querySelector("button.image-zoom-minus") @@ -290,6 +291,16 @@ for (const testReport of document.getElementsByClassName("test-report")) { images, }; + // CTRL+scroll and pinch to zoom. + imageDiff.addEventListener("wheel", /** @param e {WheelEvent} */ e => { + if (e.ctrlKey) { + e.preventDefault(); + + imageZoom.value = zoomFactor(Number(imageZoom.value), e); + imageDiffChanged(imageState); + } + }); + // Issue a lazy canvas redaw when the images have been decoded. let numDecoded = 0; for (const img of images) { @@ -314,6 +325,19 @@ for (const testReport of document.getElementsByClassName("test-report")) { } } +/** + * @param previous {number} + * @param e {WheelEvent} + * @return {number} + */ +function zoomFactor(previous, e) { + // Make zooming more proportional to the current zoom factor. + // When zoomed in closely apply a larger delta because the change from 7x to + // 7.1x isn't as noticable as from 0.5x to 0.6x scale. + const scaleCorrection = Math.log2(1 + previous); + return previous - 0.01 * scaleCorrection * e.deltaY; +} + let outputs = ["render", "pdf", "pdftags", "svg", "html"] /** @type {HTMLInputElement[]} */ let filterDiffFormats = [] From cab99ec6e8469f2720fed8a1ce5c55bbc69f0124 Mon Sep 17 00:00:00 2001 From: oliverpool <3864879+oliverpool@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:49:15 +0200 Subject: [PATCH 036/105] Ease the discovery of the "modulo" functions in the docs (#8660) --- crates/typst-library/src/foundations/calc.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/foundations/calc.rs b/crates/typst-library/src/foundations/calc.rs index a98d1d6b06c1..f4cfd94728bf 100644 --- a/crates/typst-library/src/foundations/calc.rs +++ b/crates/typst-library/src/foundations/calc.rs @@ -996,7 +996,7 @@ pub fn odd( value % 2 != 0 } -/// Calculates the remainder of two numbers. +/// Calculates the remainder of two numbers (signed modulo). /// /// The value `calc.rem(x, y)` always has the same sign as `x`, and is smaller /// in magnitude than `y`. @@ -1011,7 +1011,7 @@ pub fn odd( /// #calc.rem(-7, -3) \ /// #calc.rem(1.75, 0.5) /// ``` -#[func(title = "Remainder", since = "0.3.0")] +#[func(title = "Remainder", since = "0.3.0", keywords = ["modulo", "modulus"])] pub fn rem( span: Span, /// The dividend of the remainder. @@ -1080,7 +1080,8 @@ pub fn div_euclid( .at(span) } -/// This calculates the least nonnegative remainder of a division. +/// This calculates the least nonnegative remainder of a division (nonnegative +/// modulo). /// /// Warning: Due to a floating point round-off error, the remainder may equal /// the absolute value of the divisor if the dividend is much smaller in From b42a2bb83a75e74790af71fb7b25760967a38836 Mon Sep 17 00:00:00 2001 From: Martin Haug Date: Thu, 9 Jul 2026 18:42:08 +0200 Subject: [PATCH 037/105] First-baseline align tables in HTML docs --- docs/assets/docs.css | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/assets/docs.css b/docs/assets/docs.css index ddb7450c20d9..1117c40471f8 100644 --- a/docs/assets/docs.css +++ b/docs/assets/docs.css @@ -261,6 +261,7 @@ main table thead { main table td, main table th { padding: 4px 8px; + vertical-align: baseline; } main table td p:first-child { From 3f043b1f29685c734d87c394a2edfb7fbae5ce1f Mon Sep 17 00:00:00 2001 From: Martin Haug Date: Thu, 9 Jul 2026 18:42:08 +0200 Subject: [PATCH 038/105] Refactor folding-details Print the title on paged if given, remove details, unify styles --- docs/assets/docs.css | 38 +++++++++++++++++++++------- docs/components/base.typ | 34 ++++++++++--------------- docs/components/category.typ | 2 +- docs/content/tutorial/3-advanced.typ | 8 +++--- 4 files changed, 47 insertions(+), 35 deletions(-) diff --git a/docs/assets/docs.css b/docs/assets/docs.css index 1117c40471f8..a2a3e97393a7 100644 --- a/docs/assets/docs.css +++ b/docs/assets/docs.css @@ -420,15 +420,15 @@ pre > button.copy > svg { margin-block-start: 16px; } -.code-definition + .folding-example { +.code-definition + .folding-details { margin-block-start: 8px; } -.folding-example + .code-definition { +.folding-details + .code-definition { margin-block-start: 16px; } -.folding-example summary { +.folding-details summary { display: inline-flex; gap: 8px; font-size: 13px; @@ -440,28 +440,48 @@ pre > button.copy > svg { transition: ease 0.1s background-color; } -.folding-example summary:active { +.folding-details summary:active { background-color: #d7d9e0; } -.folding-example summary::marker, -.folding-example summary::-webkit-details-marker { +.folding-details summary::marker, +.folding-details summary::-webkit-details-marker { display: none; content: ""; } -.folding-example summary img { +.folding-details summary img { transition: ease 0.2s transform; } -.folding-example[open] summary img { +.folding-details[open] summary img { transform: rotate(90deg); } -.folding-example > div { +.folding-details > div { margin-top: 6px; } +.info-box .folding-details summary { + display: flex; + font-size: unset; + color: unset; + border-radius: unset; + padding: 0px; +} + +.info-box .folding-details { + margin-block-end: 8px; +} + +.info-box .folding-details summary:active { + background: unset; +} + +.info-box .folding-details summary img { + margin-block-start: 0.125em; +} + /* Chain link component */ a.chain img { diff --git a/docs/components/base.typ b/docs/components/base.typ index 8f3b694e444a..8e89afee9062 100644 --- a/docs/components/base.typ +++ b/docs/components/base.typ @@ -188,14 +188,20 @@ // Displays a foldable details block or just the content in paged export. #let folding-details(title: none, open: false, body) = context { - if target() == "paged" { return body } - html.details(class: "folding-example", open: open, { - html.summary({ - icon(16, "chevron-right", "Expand") - title - }) + assert.ne(title, none) + if target() == "paged" { + emph(underline(title)) + parbreak() body - }) + } else { + html.details(class: "folding-details", open: open, { + html.summary({ + icon(16, "chevron-right", "Expand") + title + }) + body + }) + } } // Displays a string containing plain text and backticks as text with inline raw @@ -268,20 +274,6 @@ } } -// Displays a combination of a summary and a body. -#let details(summary, body) = context { - if target() == "paged" { - emph(summary) - parbreak() - body - } else { - html.details({ - html.summary(summary) - body - }) - } -} - // Displays a keyboard shortcut. Takes a string rather than content. #let kbd(shortcut) = context { if target() == "paged" { diff --git a/docs/components/category.typ b/docs/components/category.typ index 80ace69ce6a8..98e53c526ab2 100644 --- a/docs/components/category.typ +++ b/docs/components/category.typ @@ -214,7 +214,7 @@ )) .flatten(), ) - if strings.len() > 10 { + context if strings.len() > 10 and target() == "html" { folding-details(title: [View options], t) } else { t diff --git a/docs/content/tutorial/3-advanced.typ b/docs/content/tutorial/3-advanced.typ index 84d8f3a0d7a2..65cd5118e967 100644 --- a/docs/content/tutorial/3-advanced.typ +++ b/docs/content/tutorial/3-advanced.typ @@ -1,5 +1,5 @@ #import "../../components/index.typ": ( - checked-list, details, docs-chapter, docs-figure, example, info, + checked-list, docs-chapter, docs-figure, example, folding-details, info, short-or-long, ) @@ -299,9 +299,9 @@ First, notice how we called the title function with empty, round parentheses. Be Next, take a look at the header. Instead of the title in square parentheses, we used the context keyword to access the document title. This inserted exactly what we set above. The role of context is not limited to accessing properties: With it, you can check if some elements are present in the document, measure the physical dimensions of others, and more. Using context, you can build powerful templates that react to the preferences of the end-user. #info[ - #details[ - Why is the context keyword required to access element properties? - ][ + #folding-details( + title: [Why is the context keyword required to access element properties?], + )[ Normally, when we access a variable, we know exactly what its value is going to be: - The variable could be a constant built into Typst, like `[#sym.pi]` From ad8e9bcec1dfb88ef3e927366b59add4070423af Mon Sep 17 00:00:00 2001 From: Martin Haug Date: Thu, 9 Jul 2026 18:42:08 +0200 Subject: [PATCH 039/105] Filter and reorder raw syntaxes available to IDE and docs --- Cargo.lock | 1 + Cargo.toml | 1 + crates/typst-library/Cargo.toml | 1 + crates/typst-library/src/text/raw.rs | 34 ++++++++++++++++++---------- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c563d1c24e55..22ef6623275e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3356,6 +3356,7 @@ dependencies = [ "glidesort", "hayagriva", "hayro-syntax", + "icu_collator", "icu_properties", "image", "indexmap", diff --git a/Cargo.toml b/Cargo.toml index d94f9c220568..c66535173ff9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ hayro-svg = { version = "0.7.0", default-features = false } hayro-syntax = "0.7.2" heck = "0.5" hypher = "0.1.4" +icu_collator = { version = "2.2.0" } icu_properties = { version = "2.2.0", features = ["serde"] } icu_provider = { version = "2.2.0", features = ["sync"] } icu_provider_blob = "2.2.0" diff --git a/crates/typst-library/Cargo.toml b/crates/typst-library/Cargo.toml index 1bee141ecb1d..33140a925ab5 100644 --- a/crates/typst-library/Cargo.toml +++ b/crates/typst-library/Cargo.toml @@ -33,6 +33,7 @@ fontdb = { workspace = true } glidesort = { workspace = true } hayagriva = { workspace = true } hayro-syntax = { workspace = true } +icu_collator = { workspace = true } icu_properties = { workspace = true } image = { workspace = true } indexmap = { workspace = true } diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index b0747bf98ca3..3f2f7cc69327 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -4,6 +4,8 @@ use std::sync::{Arc, LazyLock}; use comemo::Tracked; use ecow::{EcoString, EcoVec}; +use icu_collator::options::CollatorOptions; +use icu_collator::{Collator, CollatorPreferences}; use syntect::highlighting as synt; use syntect::parsing::{ParseSyntaxError, SyntaxDefinition, SyntaxSet, SyntaxSetBuilder}; use typst_syntax::{LinkedNode, Span, Spanned, split_newlines}; @@ -463,21 +465,29 @@ impl RawElem { impl RawElem { /// The supported language names and tags. pub fn languages() -> Vec<(&'static str, Vec<&'static str>)> { - RAW_SYNTAXES + let typst = [ + ("Typst", vec!["typ", "typst"]), + ("Typst (code)", vec!["typc"]), + ("Typst (math)", vec!["typm"]), + ]; + + let other = RAW_SYNTAXES .syntaxes() .iter() + .filter(|syntax| !syntax.file_extensions.is_empty()) .map(|syntax| { - ( - syntax.name.as_str(), - syntax.file_extensions.iter().map(|s| s.as_str()).collect(), - ) - }) - .chain([ - ("Typst", vec!["typ"]), - ("Typst (code)", vec!["typc"]), - ("Typst (math)", vec!["typm"]), - ]) - .collect() + let name = syntax.name.as_str(); + let exts = syntax.file_extensions.iter().map(|s| s.as_str()).collect(); + (name, exts) + }); + + let front = typst.len(); + let mut syntaxes: Vec<_> = typst.into_iter().chain(other).collect(); + let collator = + Collator::try_new(CollatorPreferences::default(), CollatorOptions::default()) + .unwrap(); + syntaxes[front..].sort_by(|&(a, _), &(b, _)| collator.compare(a, b)); + syntaxes } } From 9a1d84e9450c66adf9d2cb0068f4e0160e4ba34a Mon Sep 17 00:00:00 2001 From: Martin Haug Date: Thu, 9 Jul 2026 18:42:08 +0200 Subject: [PATCH 040/105] Print list of raw syntaxes in docs Closes #1511 --- crates/typst-library/src/text/raw.rs | 14 ++++++++++++++ docs/components/live.typ | 3 ++- docs/src/reflect.rs | 15 +++++++++++++++ docs/src/world.rs | 1 + 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index 3f2f7cc69327..3258d10ba46c 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -334,6 +334,17 @@ pub struct RawElem { /// @reference:syntax:code[Typst code], and /// @reference:syntax:math[Typst math], respectively. /// + /// #folding-details( + /// title: [Available syntaxes], + /// docs-table( + /// table.header[Name][Tags], + /// ..stdx.raw-langs.map(((name, tokens)) => ( + /// name, + /// tokens.map(raw).join[, ], + /// )).flatten() + /// ) + /// ) + /// /// ````example /// ```typ /// This is *Typst!* @@ -374,6 +385,9 @@ pub struct RawElem { /// - Raw bytes from which the syntax should be decoded. /// - An array where each item is one of the above. /// + /// For a list of built-in syntaxes, see the documentation of the + /// @raw.lang[`lang` parameter]. + /// /// ````example /// #set raw(syntaxes: "SExpressions.sublime-syntax") /// diff --git a/docs/components/live.typ b/docs/components/live.typ index 50d343fd7162..124193545460 100644 --- a/docs/components/live.typ +++ b/docs/components/live.typ @@ -2,7 +2,7 @@ // // Cooperates with `docs/src/live.rs`. -#import "base.typ": short-or-long +#import "base.typ": folding-details, short-or-long #import "example.typ": example #import "figure.typ": docs-figure #import "table.typ": docs-table @@ -10,6 +10,7 @@ // The definitions that are available in Rust doc comments. #let scope = ( example: example, + folding-details: folding-details, docs-figure: docs-figure, docs-table: docs-table, short-or-long: short-or-long, diff --git a/docs/src/reflect.rs b/docs/src/reflect.rs index 12de913d0d66..60774e988092 100644 --- a/docs/src/reflect.rs +++ b/docs/src/reflect.rs @@ -14,6 +14,7 @@ use typst::foundations::{ Type, Value, cast, dict, func, }; use typst::syntax::{RootedPath, VirtualPath, VirtualRoot}; +use typst::text::RawElem; use typst_utils::DefSite; use unicode_math_class::MathClass; use unicode_segmentation::UnicodeSegmentation; @@ -278,3 +279,17 @@ pub fn is_global_html_attr(name: EcoString) -> bool { .iter() .any(|global| global.name == name) } + +/// Returns the list of raw languages available. +pub fn raw_langs() -> Array { + RawElem::languages() + .into_iter() + .map(|(name, tokens)| { + dict! { + "name" => name, + "tokens" => tokens, + } + .into_value() + }) + .collect() +} diff --git a/docs/src/world.rs b/docs/src/world.rs index 88e345fe3a1a..1110ca399c63 100644 --- a/docs/src/world.rs +++ b/docs/src/world.rs @@ -227,6 +227,7 @@ fn stdx_module(is_dev_version: bool) -> Module { scope.define_func::(); scope.define("commit", typst_utils::version().commit()); scope.define("shorthands", crate::reflect::shorthands()); + scope.define("raw-langs", crate::reflect::raw_langs()); scope.define("commit", display_commit(typst_utils::version().commit())); scope.define("is-dev-version", is_dev_version); Module::new("stdx", scope) From 146a58329a30f6cd38978c22c6bf0e430d8962a1 Mon Sep 17 00:00:00 2001 From: Alessio Faieta <29785188+alessiofaieta@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:28:06 +0200 Subject: [PATCH 041/105] Fix typo in `raw` documentation (#8700) --- crates/typst-library/src/text/raw.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index 3258d10ba46c..ea23eb66b0c6 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -232,7 +232,7 @@ use crate::visualize::Color; /// first character not valid for an identifier will be interpreted as starting /// the raw text. /// -/// For example, in the current verion of Typst, if a raw block starts with +/// For example, in the current version of Typst, if a raw block starts with /// `C++`, the identifier `C` will be the language tag, and the raw text will /// start with `++`. If a raw block starts with `++C`, it will have no language /// tag and the raw text will start with `++C`. From 7ad9c34b0de53a319b476574bf952e99f8bdcffd Mon Sep 17 00:00:00 2001 From: JJ Date: Fri, 31 Jul 2026 02:38:59 -0700 Subject: [PATCH 042/105] Add a clipping example to the image docs (#7666) Co-authored-by: Laurenz --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/typst-library/src/layout/container.rs | 12 ++++++--- .../typst-library/src/visualize/image/mod.rs | 26 +++++++++++++++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 22ef6623275e..aead79434d41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3153,7 +3153,7 @@ dependencies = [ [[package]] name = "typst-dev-assets" version = "0.15.1" -source = "git+https://github.com/typst/typst-dev-assets?rev=79ba7c3#79ba7c354e39d9cd0a074bc41becc05f0119eee3" +source = "git+https://github.com/typst/typst-dev-assets?rev=5a10d90#5a10d90b044e030d793e700c7c5052759621ae38" [[package]] name = "typst-docs" diff --git a/Cargo.toml b/Cargo.toml index c66535173ff9..34c23931a79c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.15.1" } typst-timing = { path = "crates/typst-timing", version = "0.15.1" } typst-utils = { path = "crates/typst-utils", version = "0.15.1" } typst-assets = { git = "https://github.com/typst/typst-assets", rev = "94dcb99" } -typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "79ba7c3" } +typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "5a10d90" } arrayvec = "0.7.4" az = "1.2" base64 = "0.22" diff --git a/crates/typst-library/src/layout/container.rs b/crates/typst-library/src/layout/container.rs index fc06267ca83d..24b49559f51f 100644 --- a/crates/typst-library/src/layout/container.rs +++ b/crates/typst-library/src/layout/container.rs @@ -16,7 +16,12 @@ use crate::visualize::{Paint, Stroke}; /// All elements except inline math, text, and boxes are block-level and cannot /// occur inside of a @par[paragraph]. The box function can be used to integrate /// such elements into a paragraph. Boxes take the size of their contents by -/// default but can also be sized explicitly. +/// default but can also be @box.width[sized] explicitly. +/// +/// Similarly to blocks, boxes can be used to @box.clip[clip] content or to give +/// it a @box.fill[background] or @box.stroke[border]. However, they should only +/// be used for these purposes if the resulting container is used in an +/// inline-level context. Otherwise, a @block is preferable. /// /// = Example /// ```example @@ -214,8 +219,9 @@ pub enum InlineItem { /// A block-level container. /// -/// Such a container can be used to separate content, size it, and give it a -/// background or border. +/// Such a container can be used to separate content, to @block.width[size] or +/// @block.clip[clip] it, or to give it a @block.fill[background] or +/// @block.stroke[border]. /// /// Blocks are also the primary way to control whether text becomes part of a /// paragraph or not. See diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index d7aff5582da8..68866c8acb52 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -48,6 +48,32 @@ use crate::visualize::image::pdf::PdfDocument; /// ], /// ) /// ``` +/// +/// = Clipping +/// You can wrap an image in a @block or @box #footnote[A box should only be +/// used if the image shall be displayed inline as part of a paragraph. +/// Otherwise, a block is preferable.] with negative @block.inset[`inset`] and +/// `{clip: true}` to clip it. Note that the clipped parts are only visually +/// hidden. The full image data is still embedded in the output (except when +/// exporting to PNG). This approach is thus not suitable for redacting parts of +/// an image. +/// +/// ```example +/// #let lynx = image("lynx.jpg", height: 150pt, fit: "cover") +/// #grid( +/// columns: 2, +/// column-gutter: 1fr, +/// // The full image on the left. +/// lynx, +/// // Two cropped parts on the right: One cropped by 80pt +/// // from the bottom and one cropped by 75pt from the top. +/// stack( +/// spacing: 5pt, +/// block(lynx, clip: true, inset: (bottom: -80pt)), +/// block(lynx, clip: true, inset: (top: -75pt)), +/// ) +/// ) +/// ``` #[elem(since = "forever", Locatable, Tagged, Synthesize, LocalName, Figurable)] pub struct ImageElem { /// A path to an image file or raw bytes making up an image in one of the From 32fd4cc3861e0ab99f4c42ca6bea281482ba9f51 Mon Sep 17 00:00:00 2001 From: I-Al-Istannen Date: Fri, 31 Jul 2026 17:31:30 +0200 Subject: [PATCH 043/105] Fix line breaking for hosts starting with a digit (#8039) Co-authored-by: Laurenz --- crates/typst-layout/src/inline/linebreak.rs | 34 +++++++++++++++++++- tests/ref/pdf/hashes.txt | 1 + tests/ref/render/linebreak-link-ip-host.png | Bin 0 -> 7903 bytes tests/ref/svg/hashes.txt | 1 + tests/suite/layout/inline/linebreak.typ | 17 ++++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/ref/render/linebreak-link-ip-host.png diff --git a/crates/typst-layout/src/inline/linebreak.rs b/crates/typst-layout/src/inline/linebreak.rs index 5e691221861a..3902fd62061a 100644 --- a/crates/typst-layout/src/inline/linebreak.rs +++ b/crates/typst-layout/src/inline/linebreak.rs @@ -706,11 +706,23 @@ fn breakpoints(p: &Preparation, mut f: impl FnMut(usize, Breakpoint)) { let mut last = 0; let mut iter = segmenter.segment_str(text).peekable(); + let mut next_url_scheme = find_url_scheme(text, 0); loop { // Special case for links. UAX #14 doesn't handle them well. let (head, tail) = text.split_at(last); - if head.ends_with("://") || tail.starts_with("www.") { + if next_url_scheme.as_ref().is_some_and(|next| next.end < last) { + next_url_scheme = find_url_scheme(text, last); + } + + // For URLs with domains as host, UAX #14 typically places a breakpoint + // after the `://`. However, it does not do so if a digit follows. For + // this reason, we also heuristically detect URL schemes and check + // whether we are within (or typically _at_) a scheme. + if head.ends_with("://") + || tail.starts_with("www.") + || next_url_scheme.as_ref().is_some_and(|it| it.contains(&last)) + { let (link, _) = link_prefix(tail); linebreak_link(link, |i| f(last + i, Breakpoint::Normal)); last += link.len(); @@ -780,6 +792,26 @@ fn breakpoints(p: &Preparation, mut f: impl FnMut(usize, Breakpoint)) { } } +/// Heuristically attempts to find the next URL scheme in the given text. +/// +/// Anything matching this grammar rule that is also followed by '://', is +/// treated as a potential URL scheme. +fn find_url_scheme(full_text: &str, base: usize) -> Option { + let text = &full_text[base..]; + let end = text.find("://")?; + let start = text[..end].trim_end_matches(is_valid_in_url_scheme).len(); + Some(base + start..base + end) +} + +/// Whether the given character is valid in a URL scheme. +/// +/// This method uses the scheme grammar from +/// . +fn is_valid_in_url_scheme(c: char) -> bool { + // scheme = ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + matches!(c, '+' | '-' | '.') || c.is_ascii_alphanumeric() +} + /// Generate breakpoints for hyphenations within a word. fn hyphenations( p: &Preparation, diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index ca2ea4d8b268..b898fb346736 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -946,6 +946,7 @@ d190e120731dd688c8f86840bd71c2de line-numbers-place-out-of-order 1a38492b52aa7916bf0af5e3658a55c3 linebreak-hyphen-nbsp 1ee35b1234b5896330e19fb521fc77c1 linebreak-link 25fdce5e713936d9a464351cacf8cd3f linebreak-link-end +880112fbbb0e8d814d8e9bd1f4e222f5 linebreak-link-ip-host 48a5a6ef3e2ed8a516524246f4cd152c linebreak-link-justify 9ca738eae90f3fea26094a3cc78c9a95 linebreak-manual 4628b238e87a91dd97047f04b3ecf6f9 linebreak-manual-consecutive diff --git a/tests/ref/render/linebreak-link-ip-host.png b/tests/ref/render/linebreak-link-ip-host.png new file mode 100644 index 0000000000000000000000000000000000000000..661a251c986416c460a8096246021d3d37c672d1 GIT binary patch literal 7903 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 JPDHLkV1n^u9=QMj literal 0 HcmV?d00001 diff --git a/tests/ref/svg/hashes.txt b/tests/ref/svg/hashes.txt index bb9114500136..0171927232a4 100644 --- a/tests/ref/svg/hashes.txt +++ b/tests/ref/svg/hashes.txt @@ -942,6 +942,7 @@ ad83b91531986cfde5131017e71661c5 linebreak-cite-punctuation ec2290f56d9653acec9fc323446976ca linebreak-hyphen-nbsp 3013687a93f65461f3bc28723753ea7a linebreak-link 1e42bc7f5b95fcbb186f11df1f3a32ac linebreak-link-end +e4bf7dbce4ade6aa9ba00abef3e6461d linebreak-link-ip-host 0ef838b8ac972bc440f3a54ffe2cf91e linebreak-link-justify 2b0b8d20a22bf9a9e2b03bf00b8df093 linebreak-manual 72b86070f0f0f14838dc5c8e83d61744 linebreak-manual-consecutive diff --git a/tests/suite/layout/inline/linebreak.typ b/tests/suite/layout/inline/linebreak.typ index 5ee874af9fd8..5c4dbb2b87d2 100644 --- a/tests/suite/layout/inline/linebreak.typ +++ b/tests/suite/layout/inline/linebreak.typ @@ -90,6 +90,23 @@ We prove $1 < 2$— \ #link("https://example.com/paren)") \ #link("https://hi.com/%%%%%%%%abcdef") \ +--- linebreak-link-ip-host paged --- +// Ensure that links with IP hosts use the same URL line-breaking behavior. +#set page(width: 100pt) +#set par(justify: true) + +#let link-padding = "lorem_ipsum_dolor_sit_amet" +#let links = ( + "http://127.0.0.1:8080/" + link-padding, + "HtTpS://1example.com/" + link-padding, + "this-is-a+cool.scheme://a.com/" + link-padding, +) + +#links.map(link).join(linebreak()) + +// This case is not yet ideal, but it should not really happen in practice. +Ähm#link("http://127.0.0.1:8080/" + link-padding) + --- linebreak-link-justify paged --- #set page(width: 240pt) #set par(justify: true) From d5d6d4e7329521557eb829c99fb88e30503315fa Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 3 Aug 2026 16:04:16 +0200 Subject: [PATCH 044/105] Fix outdated docs on HTML export in the web app (#8717) --- docs/content/reference/export/html.typ | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/content/reference/export/html.typ b/docs/content/reference/export/html.typ index d452b27de11b..9a4e139ec5b4 100644 --- a/docs/content/reference/export/html.typ +++ b/docs/content/reference/export/html.typ @@ -1,4 +1,4 @@ -#import "../../../components/index.typ": docs-category, info +#import "../../../components/index.typ": docs-category, info, insertion #show: docs-category.with( title: "HTML", @@ -46,7 +46,7 @@ ) #info[ - Typst's HTML export is currently under active development. The feature is still very incomplete and only available for experimentation behind a feature flag. Do not use this feature for production use cases. In the CLI, you can experiment with HTML export by passing `--features html` or setting the `TYPST_FEATURES` environment variable to `html`. In the web app, HTML export is not available at this time. Visit the #link("https://github.com/typst/typst/issues/5512")[tracking issue] to follow progress on HTML export and learn more about planned features. + Typst's HTML export is currently under active development. The feature is still very incomplete and only available for experimentation behind a feature flag. Do not use this feature for production use cases. In the CLI, you can experiment with HTML export by passing `--features html` or setting the `TYPST_FEATURES` environment variable to `html`. In the web app, you can likewise enable it as an experimental feature. Visit the #link("https://github.com/typst/typst/issues/5512")[tracking issue] to follow progress on HTML export and learn more about planned features. ] HTML files describe a document structurally. The aim of Typst's HTML export is to capture the structure of an input document and produce semantically rich HTML that retains this structure. The resulting HTML should be accessible, human-readable, and editable by hand and downstream tools. @@ -70,7 +70,8 @@ When using `typst watch`, Typst will launch a live-reloading HTTP server. You ca - Pass `--no-serve` to disable the server altogether. == Web App -Not currently available. +HTML export can be enabled as an experimental feature in the web app. +#insertion("html-web-app") = HTML-specific functionality Typst exposes HTML-specific functionality in the global `html` module. See below for the definitions it contains. From 14087c62d22abfb153a888b15a54cf2460d05d62 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 3 Aug 2026 16:05:36 +0200 Subject: [PATCH 045/105] Bump `krilla` and `hayro` (#8716) --- Cargo.lock | 125 ++++++++++++++++++++++++--------------- Cargo.toml | 10 ++-- tests/ref/pdf/hashes.txt | 10 ++-- tests/ref/svg/hashes.txt | 6 +- 4 files changed, 91 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aead79434d41..5eb17c2c8b9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,6 +799,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100" +dependencies = [ + "bytemuck", +] + [[package]] name = "fontconfig-parser" version = "0.5.7" @@ -908,9 +917,8 @@ checksum = "f2e102e6eb644d3e0b186fc161e4460417880a0a0b87d235f2e5b8fb30f2e9e0" [[package]] name = "glifo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae9d48c6d81526ad2f9d5d6e5fddf5f6949ffbc46fcac0db0d061a6e65097019" +version = "0.1.1" +source = "git+https://github.com/linebender/vello?rev=8442ef4#8442ef44019568a05aeb6a04451e5cf503a6c406" dependencies = [ "bytemuck", "foldhash", @@ -918,7 +926,7 @@ dependencies = [ "log", "peniko", "png", - "skrifa", + "skrifa 0.42.1", "smallvec", "vello_common", ] @@ -942,6 +950,18 @@ dependencies = [ "crunchy", ] +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "read-fonts 0.41.0", + "smallvec", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -982,29 +1002,27 @@ dependencies = [ [[package]] name = "hayro" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4caa128ab87fd48ffb7490617cf93f77f606820dffbc9fd9ef6ab0ed077f56d" +version = "0.7.0" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "bytemuck", "hayro-interpret", "image", "kurbo", "pic-scale", + "rustc-hash", "vello_cpu", ] [[package]] name = "hayro-ccitt" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" [[package]] name = "hayro-cmap" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "hayro-postscript", ] @@ -1012,8 +1030,7 @@ dependencies = [ [[package]] name = "hayro-interpret" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "bitflags 2.11.1", "hayro-cmap", @@ -1023,7 +1040,7 @@ dependencies = [ "phf 0.13.1", "rustc-hash", "siphasher", - "skrifa", + "skrifa 0.42.1", "smallvec", "yoke", ] @@ -1031,8 +1048,7 @@ dependencies = [ [[package]] name = "hayro-jbig2" version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "fearless_simd", "hayro-ccitt", @@ -1040,9 +1056,8 @@ dependencies = [ [[package]] name = "hayro-jpeg2000" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +version = "0.4.0" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "fearless_simd", ] @@ -1050,19 +1065,18 @@ dependencies = [ [[package]] name = "hayro-postscript" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" [[package]] name = "hayro-svg" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7434c89e920d84e077214a29e69b462a6518754610f732f1124af3948410cb8c" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "base64", "hayro-interpret", "image", "kurbo", + "rustc-hash", "siphasher", "xmlwriter", ] @@ -1070,8 +1084,7 @@ dependencies = [ [[package]] name = "hayro-syntax" version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "flate2", "hayro-ccitt", @@ -1086,12 +1099,12 @@ dependencies = [ [[package]] name = "hayro-write" version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a" +source = "git+https://github.com/LaurenzV/hayro?rev=d8e24e29eda62581e1ea15497effb2a884fb1608#d8e24e29eda62581e1ea15497effb2a884fb1608" dependencies = [ "flate2", "hayro-syntax", "pdf-writer", + "rustc-hash", ] [[package]] @@ -1485,15 +1498,16 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +source = "git+https://github.com/LaurenzV/krilla?rev=7772dbe#7772dbee5ba576b7b7afe2c59cbe4c2fefe7ce9c" dependencies = [ "base64", "bumpalo", "comemo", + "crc32fast", "flate2", "float-cmp", "gif", + "harfrust", "hayro-write", "image-webp", "imagesize", @@ -1502,9 +1516,8 @@ dependencies = [ "png", "rayon", "rustc-hash", - "rustybuzz", "siphasher", - "skrifa", + "skrifa 0.44.0", "smallvec", "subsetter", "tiny-skia-path", @@ -1516,14 +1529,13 @@ dependencies = [ [[package]] name = "krilla-svg" version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1237d7c37b16ca9fbc2e72dde13a10d321f9a44aceee35c062bc0a43bfc7ce16" +source = "git+https://github.com/LaurenzV/krilla?rev=7772dbe#7772dbee5ba576b7b7afe2c59cbe4c2fefe7ce9c" dependencies = [ "flate2", "fontdb", "krilla", "resvg", - "skrifa", + "skrifa 0.44.0", "smallvec", "tiny-skia", "usvg", @@ -1811,9 +1823,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" @@ -2302,7 +2314,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", - "font-types", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.2", + "once_cell", ] [[package]] @@ -2664,7 +2687,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.39.2", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", ] [[package]] @@ -2757,7 +2790,7 @@ checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" dependencies = [ "kurbo", "rustc-hash", - "skrifa", + "skrifa 0.42.1", "write-fonts", ] @@ -3818,9 +3851,8 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vello_common" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" +version = "0.0.9" +source = "git+https://github.com/linebender/vello?rev=8442ef4#8442ef44019568a05aeb6a04451e5cf503a6c406" dependencies = [ "bytemuck", "fearless_simd", @@ -3835,9 +3867,8 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +version = "0.0.9" +source = "git+https://github.com/linebender/vello?rev=8442ef4#8442ef44019568a05aeb6a04451e5cf503a6c406" dependencies = [ "bytemuck", "glifo", @@ -4121,11 +4152,11 @@ version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" dependencies = [ - "font-types", + "font-types 0.11.3", "indexmap", "kurbo", "log", - "read-fonts", + "read-fonts 0.39.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 34c23931a79c..d0f7a7dabddb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,9 +62,9 @@ fontdb = { version = "0.23", default-features = false } fs_extra = "1.3" glidesort = "0.1.2" hayagriva = "0.10.1" -hayro = { version = "0.7.1", default-features = false } -hayro-svg = { version = "0.7.0", default-features = false } -hayro-syntax = "0.7.2" +hayro = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea15497effb2a884fb1608", default-features = false } +hayro-svg = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea15497effb2a884fb1608", default-features = false } +hayro-syntax = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea15497effb2a884fb1608" } heck = "0.5" hypher = "0.1.4" icu_collator = { version = "2.2.0" } @@ -77,8 +77,8 @@ indexmap = { version = "2", features = ["serde", "rayon"] } infer = { version = "0.19.0", default-features = false } itoa = "1" kamadak-exif = "0.6" -krilla = { version = "0.8.2", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } -krilla-svg = "0.8.1" +krilla = { git = "https://github.com/LaurenzV/krilla", rev = "7772dbe", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } +krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "7772dbe" } kurbo = "0.13" libfuzzer-sys = "0.4" libm = "0.2.11" diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index b898fb346736..32ba689d32b1 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -633,10 +633,10 @@ b68fe0d021f382a465a2b8fa087a6f06 hyphenate-pt-repeat-hyphen-hyphenate-true-with- 364849ef231ce5da69cd5c34669fae09 image-jpg e2c28abda075f98a1be7605f762b4291 image-jump-to-next-page 07039aa01c420df97eccf75a61fe6c2d image-natural-dpi-sizing -73695cb9ba910c56ab3ad05489d7bfff image-pdf-base14-fonts -512b7fb1ea82c70fc1f0ed5c2de37385 image-pdf-basic -b6648825c98d9c8a22a9bda16ca03bd9 image-pdf-complex -ab0876e21759fffe0a094f141db3dad4 image-pdf-multiple-pages +7376a585bfc1770fa96a59d25a44ccc0 image-pdf-base14-fonts +12c451b76f6dd657234c15882db48c88 image-pdf-basic +eafec7f53413c999a3b3b4446d4e3590 image-pdf-complex +9af894b778741a21c23a0e7481da934b image-pdf-multiple-pages f692b45298c819bc72fdbd925441235a image-pixmap-luma8 d7745ce3cafb9fe332e6dfa398bddc76 image-pixmap-lumaa8 9f5604c88f6f0b71e7c52aef08adf76e image-pixmap-rgb8 @@ -648,7 +648,7 @@ cb4d72e9cc3a018f9c34e3e6bb03daec image-pixmap-rgba8 42c7a88bdb12de376dade21c6a511195 image-svg-complex 36f9e8783c9bc375d0ffbaf1d8801b64 image-svg-linked-jpg1 0479e49319ad36244b3f60c32587de70 image-svg-linked-jpg2 -e93d7c9432f08f7fc399bd77c6ec97f9 image-svg-linked-many-formats +d8574ad73ed82ed07774b6087b70ec0f image-svg-linked-many-formats d05b7da1f1d97090f754947cf35aad77 image-svg-text 10665a9d33a3ea4626a24fb49769456e image-svg-text-font df3bb9369dd6be9214558c66dff3fd09 image-svg-variable-font diff --git a/tests/ref/svg/hashes.txt b/tests/ref/svg/hashes.txt index 0171927232a4..9d33dd060325 100644 --- a/tests/ref/svg/hashes.txt +++ b/tests/ref/svg/hashes.txt @@ -631,10 +631,10 @@ f84a90a3c38898ed90253f356fd88fc7 image-decode-specify-format 426f0a2ff48d007ec601e372c344dd17 image-jpg 11a920086a593d17a221a3b53410b313 image-jump-to-next-page f0efdc4f903d354f49fc52ae6fbaff82 image-natural-dpi-sizing -a3174a704e367b8359abb10eea8f2219 image-pdf-base14-fonts +e2e9531be0c7c8805e6d178dd4ad8cfc image-pdf-base14-fonts a1d173f3647a1993e27aba41e7df301d image-pdf-basic -97e84cff8efc0b0b62547df9d2eb6d23 image-pdf-complex -d680ac666465e4774d2d7ca250881798 image-pdf-multiple-pages +3ec779eaf2a2c8226dbb5ba22dd38069 image-pdf-complex +f7dab754704b2cb9a67cfdac1aef8028 image-pdf-multiple-pages 6d0be651e0c2a431806480976e947426 image-pixmap-luma8 2880b102c6b76ae1fcf2691de5ce7832 image-pixmap-lumaa8 a63af691bb0aa3d59819cedd237ebcc4 image-pixmap-rgb8 From 45b81ce0787fb0ee91c04b8f3804ba51d2e030f0 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 3 Aug 2026 16:32:43 +0200 Subject: [PATCH 046/105] Fix logical indices of hyphens with respect to surrounding tags (#8714) Co-authored-by: Laurenz --- crates/typst-layout/src/inline/line.rs | 73 +++++++++++++++++--- tests/ref/pdf/hashes.txt | 6 +- tests/ref/pdftags/hashes.txt | 2 + tests/ref/render/hyphenate-repeat-style.png | Bin 781 -> 775 bytes tests/ref/svg/hashes.txt | 2 +- tests/suite/pdftags/link.typ | 10 +++ 6 files changed, 78 insertions(+), 15 deletions(-) diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs index a4d33a9d0780..f935ee0ef424 100644 --- a/crates/typst-layout/src/inline/line.rs +++ b/crates/typst-layout/src/inline/line.rs @@ -155,26 +155,27 @@ pub fn line<'a>( // Collect the items for the line. let mut items = Items::new(); + collect_items(&mut items, engine, p, range, &trim); + // Add a hyphen at the line start, if a previous dash should be repeated. if let Some(pred) = pred && pred.dash == Some(Dash::Hard) - && let Some(base) = pred.items.trailing_text() - && should_repeat_hyphen(base.lang, full) + && let Some(pred_text) = pred.items.trailing_text() + && should_repeat_hyphen(pred_text.lang, full) + && let Some((base_idx, base)) = items.leading_text_indexed() && let Some(hyphen) = ShapedText::hyphen(engine, p.config.fallback, base, trim.shaping, false) { - items.push(Item::Text(hyphen), LogicalIndex::START_HYPHEN); + items.prepend(Item::Text(hyphen), base_idx.before()); } - collect_items(&mut items, engine, p, range, &trim); - // Add a hyphen at the line end, if we ended on a soft hyphen. if dash == Some(Dash::Soft) - && let Some(base) = items.trailing_text() + && let Some((base_idx, base)) = items.trailing_text_indexed() && let Some(hyphen) = ShapedText::hyphen(engine, p.config.fallback, base, trim.shaping, true) { - items.push(Item::Text(hyphen), LogicalIndex::END_HYPHEN); + items.push(Item::Text(hyphen), base_idx.after()); } // Ensure that there is no weak spacing at the start and end of the line. @@ -706,6 +707,11 @@ impl<'a> Items<'a> { self.0.push((idx, entry.into())); } + /// Insert a new item at the start of the line. + pub fn prepend(&mut self, entry: impl Into>, idx: LogicalIndex) { + self.0.insert(0, (idx, entry.into())); + } + /// Iterate over the items. pub fn iter(&self) -> impl DoubleEndedIterator> { self.0.iter().map(|(_, item)| &**item) @@ -727,6 +733,14 @@ impl<'a> Items<'a> { self.0.iter().find(|(_, item)| !item.is_tag())?.1.text() } + /// Access the first item (skipping tags), if it is text. + pub fn leading_text_indexed(&self) -> Option<(LogicalIndex, &ShapedText<'a>)> { + self.0 + .iter() + .find(|(_, item)| !item.is_tag()) + .and_then(|(idx, item)| Some((*idx, item.text()?))) + } + /// Access the first item (skipping tags) mutably, if it is text. pub fn leading_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { self.0.iter_mut().find(|(_, item)| !item.is_tag())?.1.text_mut() @@ -737,6 +751,15 @@ impl<'a> Items<'a> { self.0.iter().rev().find(|(_, item)| !item.is_tag())?.1.text() } + /// Access the last item (skipping tags), if it is text. + pub fn trailing_text_indexed(&self) -> Option<(LogicalIndex, &ShapedText<'a>)> { + self.0 + .iter() + .rev() + .find(|(_, item)| !item.is_tag()) + .and_then(|(idx, item)| Some((*idx, item.text()?))) + } + /// Access the last item (skipping tags) mutably, if it is text. pub fn trailing_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { self.0.iter_mut().rev().find(|(_, item)| !item.is_tag())?.1.text_mut() @@ -784,14 +807,42 @@ impl Debug for Items<'_> { pub struct LogicalIndex(usize); impl LogicalIndex { - const START_HYPHEN: Self = Self(0); - const END_HYPHEN: Self = Self(usize::MAX); - /// Create a logical index from the index of an item in the [`p.items`](Preparation::items). const fn from_item_index(i: usize) -> Self { // This won't overflow because the `idx` comes from a vector which is // limited to `isize::MAX` elements. - Self(i + 1) + // Create indices in a way that there is always a space before the + // first, between two and (most likely) after the last one. This allows + // generating logical indices for soft hyphens. One space is enough + // because there is at most a single leading and trailing item + // before/after the first/last text item in the line. + Self(2 * i + 1) + } + + /// Create a new logical index directly before this one. + /// + /// Should only be called on a "real" logical index, not one that was + /// created using [`Self::before`] or [`Self::after`], because there is only + /// room for a single additional logical index between two items. + const fn before(self) -> Self { + Self( + self.0 + .checked_sub(1) + .expect("can't create logical index before this one"), + ) + } + + /// Create a new logical index directly after this one. + /// + /// Should only be called on a "real" logical index, not one that was + /// created using [`Self::before`] or [`Self::after`], because there is only + /// room for a single additional logical index between two items. + const fn after(self) -> Self { + Self( + self.0 + .checked_add(1) + .expect("can't create logical index after this one"), + ) } } diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index 32ba689d32b1..3e542d2222a3 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -609,7 +609,7 @@ bfac906e0fb86ffa78c8efd57d464c9a highlight-edges-bounds b57230d6caff28fc5c6c5a525fa442f5 highlight-stroke c2a6755b44ce0b28a33cc41263d674f2 html-frame-in-layout d531529ec494f9ede036cedacfec6180 hyphenate -cb09693b5121fa44e74a73d603388b6e hyphenate-between-shape-runs +709f2a372bd8e45fe89f72c0608c9942 hyphenate-between-shape-runs 7573dd55555dbe6dac5bc2b5b5b059c5 hyphenate-es-capitalized-names 33564090f2f0de63afa2b21a773e14fb hyphenate-es-repeat-hyphen 981274da03a75e1a5d14cb7429cb9cdb hyphenate-off-temporarily @@ -620,7 +620,7 @@ d1ffcae7a3916ebfac56bd64bf487e33 hyphenate-pt-repeat-hyphen-hyphenate-true b68fe0d021f382a465a2b8fa087a6f06 hyphenate-pt-repeat-hyphen-hyphenate-true-with-emphasis 4cdc7e615dcf9d57595b425b94477d95 hyphenate-pt-repeat-hyphen-natural-word-breaking 58d76a2f5a600350f9c492efd07b432b hyphenate-punctuation -4bce4045f945e54c7dd1dc23fdf090d9 hyphenate-repeat-style +9bbb986722bc815ee1cf9306aae051b5 hyphenate-repeat-style 1c2b5e79c2c5114abf08d80e7f938d22 hyphenate-shy 69d174a3649c1d0cd586f6baab1074c3 if-condition-complex 4474ed26cbaa657e2986aa03d953c047 if-markup @@ -877,7 +877,7 @@ f2f89d9af70adc43d65dc6f5f17706c7 issue-grid-base-auto-row-list 82a168477d3e35944d186ad48379b975 issue-grid-gutter-skip 6b54adea6d9f616e09556230128aff98 issue-grid-skip 41442b0eca61961d26c0d0efd5e1ac78 issue-grid-skip-list -4e8df9a8dbe5696c2222b622ed9fb482 issue-hyphenate-after-tag +292e575d4a87cb05cb421c538ca31679 issue-hyphenate-after-tag 604a2ef11b220e6c1525ba1a40685611 issue-hyphenate-in-link 4cd4c3ea70c9ada25ccdda479a4444a3 issue-math-realize-hide 9fb27e62d6e71eda1da71b2bd9c3f69e issue-math-realize-scripting diff --git a/tests/ref/pdftags/hashes.txt b/tests/ref/pdftags/hashes.txt index 17132874a504..32af833eeb0e 100644 --- a/tests/ref/pdftags/hashes.txt +++ b/tests/ref/pdftags/hashes.txt @@ -49,6 +49,8 @@ b32143e1f765df4cde0fec032eeca669 issue-7301-link-tags-empty-link-body-footnote f5f3604f1588538567ec71405654998a issue-7301-link-tags-empty-link-body-linebreak 375c458d2f076f603c2d001d9ff5bbb8 issue-7301-link-tags-empty-link-body-mutliple 5ab61f7e0ec959263cf94e8a7c2c2740 issue-7763-text-tags-span-order +c224c9b9de88cc28465647ad99ac5c01 issue-7777-missing-link-parent +45c9cfe17a8402f6c0869fd5267dce5e issue-7777-missing-link-parent-repeated-hyphen 4c9538b1955e189fda33054d5f5fc50a issue-7789-list-tags-breaking a82a398895d6aa9fbf754e9bccf1fc73 lang-tags-pars-basic 1d4724e9204407592d0ed20eeb376b51 lang-tags-propagation diff --git a/tests/ref/render/hyphenate-repeat-style.png b/tests/ref/render/hyphenate-repeat-style.png index 5ce57682a6e0cd458c7453477d2e22296009e3c0..8524bc6049bb30809cad681f5b04d69e9411fbe3 100644 GIT binary patch delta 751 zcmVfpj|kuXS*6P@~bP)oOB@S8GX0$;5Gy3=k_pS65ei zJf7OxTAR&w%71FN+k1O^0T8QFsf?*e9J){ARRr?DQw{vL9*y@~JN3etM` z8@F2F_hrXS2KRcvO%-Ye#)q-=0lAvCJ?-y;fBlKuJ zgg(WI4Dc^toXBGaa@Bg7-loTm8&`3V0D%6xlxPT`M~1BpvR4`v(4s4U|BhzkwuFub z9eMI-eeJ~02m}F^NgP!wN#o0z@zzVnNj>`H*x8~nP~WHF24H%EN5kNz=;z@fdlxV( hvob5QGV6t~{sFm<+orx}qSgQa002ovPDHLkV1m1GaX$b6 delta 757 zcmV2ctc7Y_SXfwIUQQ$uEX&4Xv43;38jZ%XvNDUs0>JP0 zS5{U^BodzI@mI;^a+ypPkH@R3s@B%l&U3D%rKP!beSJL{Ax@UdQLEKtH?P)`l9I{eA{ii7g08Nv z_IkawwY7G;{eP6z;c)c!_5vVQrBWG*L}q7a=jP_}VXdgB$gLY28;RBD^EEX!?d|RD z?(U{isZ-YO?ryDC3t)bJUL+EQLLrmMWV6}M+{*KOadC03t*NQuIPT!!fCM8Rk0+B! zGB$=`q*5t>hK7cfl@+(!O%~+z$HvA;67rV;63UjA7JsYN`ul%CEEbdVB|pIFbhfp% z84QL@CZkX&IyyQQ7Z=H$@$qp0EX%4?DxFS8c7wTH)uiZ6in!NEbjUQe5kOBn8lX9hQ~A@U9G7XWbm683-4 zg?5x~t%uR4IFSMN8BCLT%s{SMFVoZXdT`?^4sH_w(0`W_0rbeQwL#`eqXJrV+xs=%ryNx nIuf1*f>p2zR>3M*FNF0MGE~{8;Z|Ri00000NkvXXu0mjfqh*2N diff --git a/tests/ref/svg/hashes.txt b/tests/ref/svg/hashes.txt index 9d33dd060325..a757f51e6803 100644 --- a/tests/ref/svg/hashes.txt +++ b/tests/ref/svg/hashes.txt @@ -618,7 +618,7 @@ b95a3df82d549476c136d68ff61d6b7e hyphenate-pt-no-repeat-hyphen 59ae8100c8909da86cbf0c81ed53ff11 hyphenate-pt-repeat-hyphen-hyphenate-true-with-emphasis 4b39e884020a62f31fdf7e2f40b8e0d5 hyphenate-pt-repeat-hyphen-natural-word-breaking 7bcee0dcc6d3c9c52f568571ec3a198c hyphenate-punctuation -bf9d852bc042ff205e3cb30571cdc166 hyphenate-repeat-style +0d008dee14457a258e0212d1a4915567 hyphenate-repeat-style 86a5aebc680448aed8d00c85b01afb2e hyphenate-shy a36a11bdc11c09ada442a95f9cc2f3df if-condition-complex f4bba6b6f3088099e0fd5c13b595128b if-markup diff --git a/tests/suite/pdftags/link.typ b/tests/suite/pdftags/link.typ index ad89d1c1679f..a9cb499ac2e1 100644 --- a/tests/suite/pdftags/link.typ +++ b/tests/suite/pdftags/link.typ @@ -67,3 +67,13 @@ Look #link("https://github.com/typst/typst")[this #parbreak() thing]. --- issue-7301-link-tags-empty-link-body-mutliple pdftags --- #link("asf")[#none\ #none] #link("asf")[#none\ #none] + +--- issue-7777-missing-link-parent pdftags --- +#set text(hyphenate: true) +#block(width: 0.9cm)[#link("x")[test]ing] + +--- issue-7777-missing-link-parent-repeated-hyphen pdftags --- +// In Czech and some other slavic languages hyphens are repeated at the start +// of the next line, which can cause issues with links in PDF. +#set text(lang: "cs", hyphenate: true) +#block(width: 0.9cm)[#link("x")[test-]ing] From d1277b06ac6fcf90138194638420f5e8b34b0e73 Mon Sep 17 00:00:00 2001 From: Kirill Lukashev Date: Tue, 4 Aug 2026 16:14:11 +0700 Subject: [PATCH 047/105] More robust overhang (#8131) Co-authored-by: Ri-Nai Co-authored-by: Reina <383821630@qq.com> Co-authored-by: Laurenz --- crates/typst-layout/src/inline/collect.rs | 16 ++- crates/typst-layout/src/inline/line.rs | 105 +++++++++++------- tests/ref/pdf/hashes.txt | 3 + .../issue-8130-overhang-in-show-rule.png | Bin 0 -> 682 bytes tests/ref/render/overhang-dash.png | Bin 0 -> 560 bytes tests/ref/render/overhang-label.png | Bin 0 -> 4373 bytes tests/ref/svg/hashes.txt | 3 + tests/suite/layout/inline/overhang.typ | 39 +++++++ 8 files changed, 120 insertions(+), 46 deletions(-) create mode 100644 tests/ref/render/issue-8130-overhang-in-show-rule.png create mode 100644 tests/ref/render/overhang-dash.png create mode 100644 tests/ref/render/overhang-label.png diff --git a/crates/typst-layout/src/inline/collect.rs b/crates/typst-layout/src/inline/collect.rs index 529951b8d61c..cd596408a677 100644 --- a/crates/typst-layout/src/inline/collect.rs +++ b/crates/typst-layout/src/inline/collect.rs @@ -46,11 +46,6 @@ pub enum Item<'a> { } impl<'a> Item<'a> { - /// Whether this is a tag item. - pub fn is_tag(&self) -> bool { - matches!(self, Self::Tag(_)) - } - /// If this a text item, return it. pub fn text(&self) -> Option<&ShapedText<'a>> { match self { @@ -67,6 +62,17 @@ impl<'a> Item<'a> { } } + /// Whether this item can be skipped for the purposes of finding a leading + /// or trailing item. + pub fn is_skippable(&self) -> bool { + match self { + Self::Text(text) => text.glyphs.is_empty(), + Self::Tag(_) => true, + Self::Skip(_) => true, + _ => false, + } + } + /// Return the textual representation of this item: Either just itself (for /// a text item) or a replacement string (for any other item). pub fn textual(&self) -> &str { diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs index f935ee0ef424..d33112658414 100644 --- a/crates/typst-layout/src/inline/line.rs +++ b/crates/typst-layout/src/inline/line.rs @@ -1,16 +1,17 @@ use std::fmt::{self, Debug, Formatter}; use std::ops::{Deref, DerefMut}; +use ecow::EcoVec; use typst_library::engine::Engine; use typst_library::introspection::{SplitLocator, Tag, TagFlags}; use typst_library::layout::{Abs, Dir, Em, Fr, Frame, FrameItem, Point}; use typst_library::model::ParLineMarker; -use typst_library::text::{Lang, TextElem, families, variant}; +use typst_library::text::{FontInstance, Lang, TextElem, families, variant}; use typst_utils::Numeric; use super::*; use crate::inline::linebreak::Trim; -use crate::inline::shaping::Adjustability; +use crate::inline::shaping::{Adjustability, ShapedGlyph}; use crate::modifiers::layout_and_modify; const SHY: char = '\u{ad}'; @@ -509,7 +510,7 @@ pub fn commit( && text.styles.get(TextElem::overhang) && (line.items.len() > 1 || text.glyphs.len() > 1) { - let amount = overhang(glyph.c) * glyph.x_advance.at(glyph.size); + let amount = overhang(glyph); offset -= amount; remaining += amount; } @@ -521,7 +522,7 @@ pub fn commit( && text.styles.get(TextElem::overhang) && (line.items.len() > 1 || text.glyphs.len() > 1) { - let amount = overhang(glyph.c) * glyph.x_advance.at(glyph.size); + let amount = overhang(glyph); remaining += amount; } @@ -560,7 +561,7 @@ pub fn commit( // Build the frames and determine the height and baseline. let mut frames = vec![]; - for &(idx, ref item) in line.items.indexed_iter() { + for &(idx, ref item) in line.items.iter_indexed() { let mut push = |offset: &mut Abs, frame: Frame, idx: LogicalIndex| { let width = frame.width(); top.set_max(frame.baseline()); @@ -672,26 +673,49 @@ fn add_par_line_marker( output.push(pos, FrameItem::Tag(Tag::End(loc, key, flags))); } +/// How much a glyph should hang into the end margin. +fn overhang(glyph: &ShapedGlyph) -> Abs { + // Note: Binary search is not currently worth it, but can be used if this + // grows larger. + font_overhang_table(&glyph.font) + .iter() + .find(|(id, _)| *id == glyph.glyph_id) + .map_or(Abs::zero(), |&(_, factor)| factor * glyph.x_advance.at(glyph.size)) +} + +/// Generates a sorted table with overhang ratios indexed by glyph ID for a +/// particular font. +#[comemo::memoize] +fn font_overhang_table(font: &FontInstance) -> EcoVec<(u16, f64)> { + let mut table = EcoVec::with_capacity(DEFAULT_OVERHANG_TABLE.len()); + for &(c, factor) in DEFAULT_OVERHANG_TABLE { + if let Some(id) = font.ttf().glyph_index(c) { + table.push((id.0, factor)); + } + } + table.make_mut().sort_unstable_by_key(|(id, _)| *id); + table +} + /// How much a character should hang into the end margin. /// /// For more discussion, see: /// -fn overhang(c: char) -> f64 { - match c { - // Dashes. - '–' | '—' => 0.2, - '-' | '\u{ad}' => 0.55, - - // Punctuation. - '.' | ',' => 0.8, - ':' | ';' => 0.3, - - // Arabic - '\u{60C}' | '\u{6D4}' => 0.4, - - _ => 0.0, - } -} +const DEFAULT_OVERHANG_TABLE: &[(char, f64)] = &[ + // Dashes. + ('–', 0.2), + ('—', 0.2), + ('-', 0.55), + ('\u{ad}', 0.55), + // Punctuation. + ('.', 0.8), + (',', 0.8), + (';', 0.3), + (':', 0.3), + // Arabic + ('\u{60C}', 0.4), + ('\u{6D4}', 0.4), +]; /// A collection of owned or borrowed inline items. pub struct Items<'a>(Vec<(LogicalIndex, ItemEntry<'a>)>); @@ -722,47 +746,46 @@ impl<'a> Items<'a> { /// /// Note that this is different from `.iter().enumerate()` which would /// provide the indices in visual order! - pub fn indexed_iter( + pub fn iter_indexed( &self, ) -> impl DoubleEndedIterator)> { self.0.iter() } - /// Access the first item (skipping tags), if it is text. + /// Access the first unskippable item, if it is text. pub fn leading_text(&self) -> Option<&ShapedText<'a>> { - self.0.iter().find(|(_, item)| !item.is_tag())?.1.text() + self.iter().find(|item| !item.is_skippable())?.text() } - /// Access the first item (skipping tags), if it is text. + /// Access the first unskippable item alongside its index, if it is text. pub fn leading_text_indexed(&self) -> Option<(LogicalIndex, &ShapedText<'a>)> { - self.0 - .iter() - .find(|(_, item)| !item.is_tag()) - .and_then(|(idx, item)| Some((*idx, item.text()?))) + let (idx, item) = self.0.iter().find(|(_, item)| !item.is_skippable())?; + let text = item.text()?; + Some((*idx, text)) } - /// Access the first item (skipping tags) mutably, if it is text. + /// Access the first unskippable item mutably, if it is text. pub fn leading_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { - self.0.iter_mut().find(|(_, item)| !item.is_tag())?.1.text_mut() + let (_, item) = self.0.iter_mut().find(|(_, item)| !item.is_skippable())?; + item.text_mut() } - /// Access the last item (skipping tags), if it is text. + /// Access the last unskippable item, if it is text. pub fn trailing_text(&self) -> Option<&ShapedText<'a>> { - self.0.iter().rev().find(|(_, item)| !item.is_tag())?.1.text() + self.iter().rev().find(|item| !item.is_skippable())?.text() } - /// Access the last item (skipping tags), if it is text. + /// Access the last unskippable item alongside its index, if it is text. pub fn trailing_text_indexed(&self) -> Option<(LogicalIndex, &ShapedText<'a>)> { - self.0 - .iter() - .rev() - .find(|(_, item)| !item.is_tag()) - .and_then(|(idx, item)| Some((*idx, item.text()?))) + let (idx, item) = self.0.iter().rev().find(|(_, item)| !item.is_skippable())?; + let text = item.text()?; + Some((*idx, text)) } - /// Access the last item (skipping tags) mutably, if it is text. + /// Access the last unskippable item mutably, if it is text. pub fn trailing_text_mut(&mut self) -> Option<&mut ShapedText<'a>> { - self.0.iter_mut().rev().find(|(_, item)| !item.is_tag())?.1.text_mut() + let (_, item) = self.0.iter_mut().rev().find(|(_, item)| !item.is_skippable())?; + item.text_mut() } /// Reorder the items starting at the given index to RTL. diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt index 3e542d2222a3..54f4309fab8c 100644 --- a/tests/ref/pdf/hashes.txt +++ b/tests/ref/pdf/hashes.txt @@ -853,6 +853,7 @@ f56795c728b71c3362c549303cd72cee issue-785-cite-locate aef4ed36525eb9f423be68e32b38407f issue-7991-show-text-symbol-grapheme-sliced d9192b21589f5f341abaf84775109c68 issue-80-emoji-linebreak 6570a3a66154baf8df947d0991c2b03f issue-8058-show-text-grapheme-sliced +a39a922e13e43eb58a2e1f33d59f7534 issue-8130-overhang-in-show-rule 1111b54cb0e423e99fdd68f707fd88f7 issue-8261-string-as-ellipsis 59242c8a2139300aca77ab46d4fbf2d0 issue-8261-string-as-empty ce0a8c8453f682afc61280473b32b856 issue-8261-string-as-non-numeric @@ -1432,6 +1433,8 @@ cf07bbe7c143f8f38fcffebb9a221013 outline-par 47d8457ba737fb6634bc705c7cdf94d4 outline-spacing 4b8426eff1ab2314e9418221c9a0db68 outline-styled-text 0816a5a02da2a096a7103820ab642095 overhang +4d1c71979cd6d8644d34d47938379c54 overhang-dash +b751ab46556d191427c5fc7e6fe9d9b4 overhang-label b57129df1932e5e5cc41dcb19318775d overhang-lone edd9fbf27666f5d8566abf116c5bcaf5 overline-background 971245cabafcf282911e1dacd0962325 pad-basic diff --git a/tests/ref/render/issue-8130-overhang-in-show-rule.png b/tests/ref/render/issue-8130-overhang-in-show-rule.png new file mode 100644 index 0000000000000000000000000000000000000000..0981042b6a1ede4352e33eca46af152def47fd46 GIT binary patch literal 682 zcmV;b0#*HqP)*enmw^^ZEXu%l+^>rG8frKP3!_xJMh@^W%=m6es0l$1|TPbViQ$jHdt+}xBv$L~vb8~cb zbeo%-^z`&RJUlNiFPxm5Vv4Pto}fxfO5fk#I5;@3udi`&arO1}x3{-%Z*QBOpEfo& z7#J9MczEva?rv^w!otGZ+SgOPnamTAJ_P!IfiHDYNp%x2rYG1nF)b4l3+o(1zzOHZIs{JhtxX7A ze0z66;D={LMZlTIC)M_{yC)nbyW)<#?rekBX_UkESi)@za}Yex3xWrYAh_FjeSv|^ zSC^-liRt_d1oIgPJVH~`0C;F@d}6ZFC}$v{RrN?FlgVVQSt{A3)!#=VV(cEF1n!~xI|yv~{7?~a@%u{&Lg1fYmt%3kDjcS4oGfg97_?4v@?kFF=E7^MAb4dN z1kd8WBEk_4fz6?yA2Trpd>}aMMZo)qC;y&&y_i_Id~kI9S7;P1cfwU&1FQ=ul_0>E Qj{pDw07*qoM6N<$g0?Mb%>V!Z literal 0 HcmV?d00001 diff --git a/tests/ref/render/overhang-dash.png b/tests/ref/render/overhang-dash.png new file mode 100644 index 0000000000000000000000000000000000000000..352b322429fada630aaa6a195993523e0ee4624b GIT binary patch literal 560 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 P00000NkvXXu0mjfP-#qm literal 0 HcmV?d00001 diff --git a/tests/ref/svg/hashes.txt b/tests/ref/svg/hashes.txt index a757f51e6803..2ffe449d4e3d 100644 --- a/tests/ref/svg/hashes.txt +++ b/tests/ref/svg/hashes.txt @@ -851,6 +851,7 @@ d987856ceb0fd2f906c660cd0846c485 issue-758-link-repeat f47e8b81aae825ddeedb7f35123ae9be issue-7991-show-text-symbol-grapheme-sliced 787eee28e0ecf53513948ef24d29556d issue-80-emoji-linebreak 853132687f26819ca27e065d149969b9 issue-8058-show-text-grapheme-sliced +50d824346c6467c57430c9249576382e issue-8130-overhang-in-show-rule 1813985408c07fa8619377d9eead4083 issue-8261-string-as-ellipsis 5e076d36931fe54b76edb4ac596f3109 issue-8261-string-as-empty e757b359586c26a3453ab45df86c1591 issue-8261-string-as-non-numeric @@ -1428,6 +1429,8 @@ b20c1323451166482ad0a5b3df771456 outline-indent-zero 12cc45c21ce7f102e964bbaa53f02fca outline-spacing 0bedfe61bb25bdd184f093eac534b70c outline-styled-text ea3116a4234c78ed212e732fec731229 overhang +e424f9dad29b71453636208327eca42a overhang-dash +bd882185399012a8a57f5f79cee47a20 overhang-label c977efac56fb8124b76f2a08dd4a967d overhang-lone 46aa45caa7c3aa8d109f657d93e95e5a overline-background 83317f5281cf297eb1204129b2a54783 pad-basic diff --git a/tests/suite/layout/inline/overhang.typ b/tests/suite/layout/inline/overhang.typ index 0abef0185f75..a2dda848f755 100644 --- a/tests/suite/layout/inline/overhang.typ +++ b/tests/suite/layout/inline/overhang.typ @@ -16,6 +16,45 @@ #set text(lang: "he", font: ("PT Sans", "Noto Serif Hebrew")) בנייה נכונה של משפטים ארוכים דורשת ידע בשפה. אז בואו נדבר על מזג האוויר. +--- overhang-label paged --- +// Test that labels do not interfere with hanging punctuation. +#set page(width: 130pt) +#set par(justify: true) +#set text(size: 11pt) +#set block(stroke: 0.5pt + blue, width: 4cm) + +#block[This is a text with just, the right length.] +#block[This is a text with just,
` is tapped on touch
     // screens.
     let timeoutId;
diff --git a/docs/components/example.typ b/docs/components/example.typ
index 8c32bbeaad86..860ac64eee63 100644
--- a/docs/components/example.typ
+++ b/docs/components/example.typ
@@ -159,10 +159,12 @@
       if pre.at("label", default: none) == <_stop> {
         return pre
       }
-      let copy-button = html.button(
+      let copy-button = html.div(
         class: "copy",
-        disabled: true,
-        use-icon(16, "copy", "Copy"),
+        html.button(
+          disabled: true,
+          use-icon(16, "copy", "Copy"),
+        ),
       )
       let reconstructed = html.elem(
         "pre",

From a51e028041cac426f97d34335bb01d8f1d8e5e8f Mon Sep 17 00:00:00 2001
From: Tobias Schmitz 
Date: Fri, 7 Aug 2026 15:01:39 +0200
Subject: [PATCH 057/105] Store required features in bindings (#8599)

Co-authored-by: Laurenz 
---
 Cargo.lock                                    |   2 +-
 Cargo.toml                                    |   2 +-
 crates/typst-eval/src/access.rs               |  14 +-
 crates/typst-eval/src/call.rs                 |  39 +-
 crates/typst-eval/src/code.rs                 |  12 +-
 crates/typst-eval/src/import.rs               |  19 +-
 crates/typst-eval/src/math.rs                 |   3 +-
 crates/typst-eval/src/ops.rs                  |  24 +-
 crates/typst-eval/src/vm.rs                   |   8 +-
 crates/typst-html/src/rules.rs                |   2 +-
 crates/typst-ide/src/analyze.rs               |  16 +-
 crates/typst-ide/src/complete.rs              |  39 +-
 crates/typst-ide/src/definition.rs            |   8 +-
 crates/typst-ide/src/matchers.rs              |  26 +-
 crates/typst-ide/src/utils.rs                 |  18 +-
 crates/typst-library/src/diag.rs              |  18 +-
 crates/typst-library/src/engine.rs            |   9 +-
 crates/typst-library/src/foundations/dict.rs  |  36 +-
 crates/typst-library/src/foundations/func.rs  |  14 +-
 .../typst-library/src/foundations/module.rs   |  14 +-
 crates/typst-library/src/foundations/ops.rs   |  20 +-
 crates/typst-library/src/foundations/scope.rs | 409 +++++++++++++++---
 crates/typst-library/src/foundations/ty.rs    |  13 +-
 crates/typst-library/src/foundations/value.rs |  36 +-
 crates/typst-library/src/lib.rs               |  46 +-
 crates/typst-library/src/model/mod.rs         |   8 +-
 crates/typst-library/src/pdf/mod.rs           |  14 +-
 crates/typst-library/src/symbols.rs           |   2 +-
 crates/typst-macros/src/scope.rs              |  24 +-
 crates/typst-utils/src/bitset.rs              |   4 +-
 docs/assets/docs.css                          |   6 +-
 docs/assets/index.typ                         |   1 +
 docs/components/base.typ                      |  39 +-
 docs/components/category.typ                  | 134 ++++--
 docs/components/index.typ                     |   2 +-
 docs/content/reference/export/html.typ        |   8 +-
 docs/content/reference/export/pdf.typ         |   4 +-
 .../content/reference/library/foundations.typ |   5 +-
 docs/content/reference/library/math.typ       |  59 +--
 docs/src/reflect.rs                           |   1 +
 tests/ref/pdf/hashes.txt                      |   2 +-
 tests/ref/render/symbol-sect-deprecated.png   | Bin 439 -> 554 bytes
 tests/ref/svg/hashes.txt                      |   2 +-
 tests/src/collect.rs                          |  61 ++-
 tests/src/run.rs                              |   2 +-
 tests/src/world.rs                            |  90 +++-
 tests/suite/foundations/dict.typ              |   7 +
 tests/suite/foundations/std.typ               |   6 +
 tests/suite/math/attach.typ                   |   2 +-
 tests/suite/math/call.typ                     |   2 +-
 tests/suite/math/interactions.typ             |   2 +-
 tests/suite/scripting/blocks.typ              |   8 +-
 tests/suite/scripting/call.typ                |   6 +
 tests/suite/scripting/closure.typ             |   2 +-
 tests/suite/scripting/field.typ               |  12 +
 tests/suite/scripting/import.typ              |  57 ++-
 tests/suite/scripting/include.typ             |   2 +-
 tests/suite/scripting/ops.typ                 |  18 +-
 tests/suite/scripting/recursion.typ           |   2 +-
 tests/suite/symbols/symbol.typ                |   3 +
 60 files changed, 1058 insertions(+), 386 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 5eb17c2c8b9f..f2e5160ea070 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3186,7 +3186,7 @@ dependencies = [
 [[package]]
 name = "typst-dev-assets"
 version = "0.15.1"
-source = "git+https://github.com/typst/typst-dev-assets?rev=5a10d90#5a10d90b044e030d793e700c7c5052759621ae38"
+source = "git+https://github.com/typst/typst-dev-assets?rev=0e7c92e#0e7c92e9ede7621b1a705af014f706d91bb93bc8"
 
 [[package]]
 name = "typst-docs"
diff --git a/Cargo.toml b/Cargo.toml
index d0f7a7dabddb..d8e46cddd8eb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -34,7 +34,7 @@ typst-syntax = { path = "crates/typst-syntax", version = "0.15.1" }
 typst-timing = { path = "crates/typst-timing", version = "0.15.1" }
 typst-utils = { path = "crates/typst-utils", version = "0.15.1" }
 typst-assets = { git = "https://github.com/typst/typst-assets", rev = "94dcb99" }
-typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "5a10d90" }
+typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "0e7c92e" }
 arrayvec = "0.7.4"
 az = "1.2"
 base64 = "0.22"
diff --git a/crates/typst-eval/src/access.rs b/crates/typst-eval/src/access.rs
index 59476e1e19d6..1c699e0abe5f 100644
--- a/crates/typst-eval/src/access.rs
+++ b/crates/typst-eval/src/access.rs
@@ -1,6 +1,6 @@
 use ecow::eco_format;
 use typst_library::diag::{At, Hint, SourceResult, Trace, Tracepoint, bail};
-use typst_library::foundations::{Dict, Value};
+use typst_library::foundations::{Dict, Value, WorldBindingExt};
 use typst_syntax::ast::{self, AstNode};
 
 use crate::{Eval, Vm, call_method_access, is_accessor_method};
@@ -29,15 +29,15 @@ impl Access for ast::Expr<'_> {
 impl Access for ast::Ident<'_> {
     fn access<'a>(self, vm: &'a mut Vm) -> SourceResult<&'a mut Value> {
         let span = self.span();
+        let ident = self.as_str();
         if vm.inspected == Some(span)
-            && let Ok(binding) = vm.scopes.get(&self)
+            && let Ok(value) =
+                vm.scopes.get(ident, vm.engine.world.silent_binding_guard())
         {
-            vm.trace(binding.read().clone());
+            vm.trace(value.clone());
         }
-        vm.scopes
-            .get_mut(&self)
-            .and_then(|b| b.write().map_err(Into::into))
-            .at(span)
+
+        vm.scopes.get_mut(&self, vm.engine.binding_guard(span)).at(span)
     }
 }
 
diff --git a/crates/typst-eval/src/call.rs b/crates/typst-eval/src/call.rs
index e0832b8ac9d8..0e1a22c80536 100644
--- a/crates/typst-eval/src/call.rs
+++ b/crates/typst-eval/src/call.rs
@@ -6,8 +6,8 @@ use typst_library::diag::{
 };
 use typst_library::engine::{Engine, Sink, Traced};
 use typst_library::foundations::{
-    Arg, Args, Binding, Capturer, Closure, ClosureNode, Content, Context, Func,
-    NativeElement, Scope, Scopes, SequenceElem, SymbolElem, Value,
+    Arg, Args, Binding, BindingAccess, Capturer, Closure, ClosureNode, Content, Context,
+    Func, NativeElement, Scope, Scopes, SequenceElem, SymbolElem, Value,
 };
 use typst_library::introspection::Introspector;
 use typst_library::math::LrElem;
@@ -245,24 +245,33 @@ fn eval_field_callee(
     target: Value,
     in_math: bool,
 ) -> SourceResult {
-    let sink = (&mut vm.engine, field_span);
+    let guard = vm.engine.binding_guard(field_span);
 
     let mut is_method_call = false;
     let callee_value = if let Some(method) = target.ty().scope().get(field) {
         is_method_call = true;
-        method.read_checked(sink).clone()
+        let ty = target.ty().short_name();
+        method
+            .read(guard)
+            .or_cannot(format_args!("call method `{field}` on {ty}"))
+            .at(field_span)?
+            .clone()
     } else if let Value::Content(content) = &target
         && let Some(method) = content.elem().scope().get(field)
     {
         is_method_call = true;
-        method.read_checked(sink).clone()
+        method
+            .read(guard)
+            .or_cannot(format_args!("call method `{field}` on content"))
+            .at(field_span)?
+            .clone()
     } else if matches!(target, Value::Symbol(_) | Value::Type(_) | Value::Module(_)) {
         // These types are allowed to use field call syntax on non-methods.
-        target.field(field, sink).at(field_span)?
+        target.field(field, guard).at(field_span)?
     } else if let Value::Func(func) = &target {
         // Functions can also use field call syntax on non-methods, but not for
         // settable fields accessed from context.
-        match target.field(field, sink).at(field_span) {
+        match target.field(field, guard).at(field_span) {
             Ok(callee_value) => callee_value,
             Err(err) => {
                 if let Some(element) = func.to_element()
@@ -284,7 +293,7 @@ fn eval_field_callee(
         }
     } else {
         // Otherwise we are not allowed to call the field and produce an error.
-        match target.field(field, sink) {
+        match target.field(field, guard) {
             // The field does exist.
             Ok(callee_value) => {
                 bail!(disallowed_field_call_error(
@@ -773,9 +782,11 @@ impl<'a> CapturesVisitor<'a> {
             // Identifiers that shouldn't count as captures because they
             // actually bind a new name are handled below (individually through
             // the expressions that contain them).
-            Some(ast::Expr::Ident(ident)) => self.capture(ident.get(), Scopes::get),
+            Some(ast::Expr::Ident(ident)) => {
+                self.capture(ident.get(), Scopes::get_binding);
+            }
             Some(ast::Expr::MathIdent(ident)) => {
-                self.capture(ident.get(), Scopes::get_in_math);
+                self.capture(ident.get(), Scopes::get_binding_in_math);
             }
 
             // Code and content blocks create a scope.
@@ -899,7 +910,13 @@ impl<'a> CapturesVisitor<'a> {
         ident: &EcoString,
         getter: impl FnOnce(&'a Scopes<'a>, &str) -> HintedStrResult<&'a Binding>,
     ) {
-        if self.internal.get(ident).is_ok() {
+        if self.internal.get_binding(ident).is_ok() {
+            return;
+        }
+
+        // If the variable has already been captured, there is no need to look
+        // it up again, since the external scopes don't change.
+        if self.captures.get(ident).is_some() {
             return;
         }
 
diff --git a/crates/typst-eval/src/code.rs b/crates/typst-eval/src/code.rs
index ca63b7a59297..9572f01c9f30 100644
--- a/crates/typst-eval/src/code.rs
+++ b/crates/typst-eval/src/code.rs
@@ -160,12 +160,7 @@ impl Eval for ast::Ident<'_> {
 
     fn eval(self, vm: &mut Vm) -> SourceResult {
         let span = self.span();
-        Ok(vm
-            .scopes
-            .get(&self)
-            .at(span)?
-            .read_checked((&mut vm.engine, span))
-            .clone())
+        vm.scopes.get(&self, vm.engine.binding_guard(span)).at(span).cloned()
     }
 }
 
@@ -366,7 +361,10 @@ pub(crate) fn access_field(
     field: &str,
     field_span: Span,
 ) -> SourceResult {
-    let err = match target.field(field, (&mut vm.engine, field_span)).at(field_span) {
+    let err = match target
+        .field(field, vm.engine.binding_guard(field_span))
+        .at(field_span)
+    {
         Ok(value) => return Ok(value),
         Err(err) => err,
     };
diff --git a/crates/typst-eval/src/import.rs b/crates/typst-eval/src/import.rs
index da145af0d096..131deef3dac7 100644
--- a/crates/typst-eval/src/import.rs
+++ b/crates/typst-eval/src/import.rs
@@ -5,7 +5,9 @@ use typst_library::diag::{
     At, FileError, SourceResult, Trace, Tracepoint, bail, error, warning,
 };
 use typst_library::engine::Engine;
-use typst_library::foundations::{Binding, Content, Module, PathOrStr, Reflect, Value};
+use typst_library::foundations::{
+    Binding, BindingAccess, Content, Module, PathOrStr, Reflect, Value, WorldBindingExt,
+};
 use typst_syntax::ast::{self, AstNode, BareImportError};
 use typst_syntax::package::{PackageManifest, PackageSpec};
 use typst_syntax::{FileId, RootedPath, Span, VirtualPath, VirtualRoot};
@@ -107,7 +109,11 @@ impl Eval for ast::ModuleImport<'_> {
             }
             Some(ast::Imports::Wildcard) => {
                 for (var, binding) in scope.iter() {
-                    vm.scopes.top.bind(var.clone(), binding.clone());
+                    // Filter out values that are in feature gated bindings and
+                    // ignore any deprecation warnings that are emitted.
+                    if binding.read(vm.engine.world.silent_binding_guard()).is_ok() {
+                        vm.scopes.top.bind(var.clone(), binding.clone());
+                    }
                 }
             }
             Some(ast::Imports::Items(items)) => {
@@ -117,15 +123,20 @@ impl Eval for ast::ModuleImport<'_> {
                     let mut scope = scope;
 
                     while let Some(component) = &path.next() {
-                        let Some(binding) = scope.get(component) else {
+                        let field = component.as_str();
+                        let Some(binding) = scope.get(field) else {
                             errors.push(error!(component.span(), "unresolved import"));
                             break;
                         };
 
+                        let value = binding
+                            .read(vm.engine.binding_guard(component.span()))
+                            .or_cannot(format_args!("import `{field}`"))
+                            .at(component.span())?;
+
                         if path.peek().is_some() {
                             // Nested import, as this is not the last component.
                             // This must be a submodule.
-                            let value = binding.read();
                             let Some(submodule) = value.scope() else {
                                 let error = if matches!(value, Value::Func(function) if function.scope().is_none())
                                 {
diff --git a/crates/typst-eval/src/math.rs b/crates/typst-eval/src/math.rs
index 64235078cfb6..3eb62a7b6172 100644
--- a/crates/typst-eval/src/math.rs
+++ b/crates/typst-eval/src/math.rs
@@ -49,9 +49,8 @@ impl Eval for ast::MathIdent<'_> {
         let span = self.span();
         Ok(vm
             .scopes
-            .get_in_math(&self)
+            .get_in_math(&self, vm.engine.binding_guard(span))
             .at(span)?
-            .read_checked((&mut vm.engine, span))
             .clone())
     }
 }
diff --git a/crates/typst-eval/src/ops.rs b/crates/typst-eval/src/ops.rs
index ba3ceb74c3e5..4a78aa1c2993 100644
--- a/crates/typst-eval/src/ops.rs
+++ b/crates/typst-eval/src/ops.rs
@@ -1,6 +1,7 @@
 use ecow::eco_format;
 use typst_library::diag::{At, HintedStrResult, SourceResult, bail, error};
-use typst_library::foundations::{IntoValue, Value, ops};
+use typst_library::foundations::{IntoValue, NormalBindingGuard, Value, ops};
+use typst_syntax::Span;
 use typst_syntax::ast::{self, AstNode};
 
 use crate::{Access, Eval, Vm, access_dict};
@@ -39,8 +40,12 @@ impl Eval for ast::Binary<'_> {
             ast::BinOp::Leq => apply_binary(self, vm, ops::leq),
             ast::BinOp::Gt => apply_binary(self, vm, ops::gt),
             ast::BinOp::Geq => apply_binary(self, vm, ops::geq),
-            ast::BinOp::In => apply_binary(self, vm, ops::in_),
-            ast::BinOp::NotIn => apply_binary(self, vm, ops::not_in),
+            ast::BinOp::In => {
+                apply_binary_with(self, vm, |guard, a, b| ops::in_(guard, a, b))
+            }
+            ast::BinOp::NotIn => {
+                apply_binary_with(self, vm, |guard, a, b| ops::not_in(guard, a, b))
+            }
             ast::BinOp::Assign => apply_assignment(self, vm, |_, b| Ok(b)),
             ast::BinOp::AddAssign => apply_assignment(self, vm, ops::add),
             ast::BinOp::SubAssign => apply_assignment(self, vm, ops::sub),
@@ -69,6 +74,19 @@ fn apply_binary(
     op(lhs, rhs).at(binary.span())
 }
 
+/// Apply a basic binary operation with a [`BindingGuard`].
+///
+/// [`BindingGuard`]: typst_library::foundations::BindingGuard
+fn apply_binary_with(
+    binary: ast::Binary,
+    vm: &mut Vm,
+    op: fn(NormalBindingGuard, Value, Value) -> HintedStrResult,
+) -> SourceResult {
+    let lhs = binary.lhs().eval(vm)?;
+    let rhs = binary.rhs().eval(vm)?;
+    op(vm.engine.binding_guard(Span::detached()), lhs, rhs).at(binary.span())
+}
+
 /// Apply an assignment operation.
 fn apply_assignment(
     binary: ast::Binary,
diff --git a/crates/typst-eval/src/vm.rs b/crates/typst-eval/src/vm.rs
index a202e8141a7d..cefc28da1476 100644
--- a/crates/typst-eval/src/vm.rs
+++ b/crates/typst-eval/src/vm.rs
@@ -3,7 +3,9 @@ use ecow::eco_format;
 use typst_library::World;
 use typst_library::diag::{HintedString, warning};
 use typst_library::engine::Engine;
-use typst_library::foundations::{Binding, Context, IntoValue, Scopes, Value};
+use typst_library::foundations::{
+    Binding, Context, IntoValue, Scopes, Value, WorldBindingExt,
+};
 use typst_syntax::Span;
 use typst_syntax::ast::{self, AstNode};
 
@@ -56,7 +58,9 @@ impl<'a> Vm<'a> {
     /// This will insert the value into the top-most scope and make it available
     /// for dynamic tracing, assisting IDE functionality.
     pub fn bind(&mut self, var: ast::Ident, binding: Binding) {
-        self.trace_at(var.span(), binding.read());
+        if let Ok(value) = binding.read(self.engine.world.silent_binding_guard()) {
+            self.trace_at(var.span(), value);
+        }
 
         // This will become an error in the parser if `is` becomes a keyword.
         if var.get() == "is" {
diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs
index 9706436371c5..8451b6684f5d 100644
--- a/crates/typst-html/src/rules.rs
+++ b/crates/typst-html/src/rules.rs
@@ -790,7 +790,7 @@ const IMAGE_RULE: ShowFn = |elem, engine, styles| {
     attrs.push(attr::width, cast(image.width()));
     attrs.push(attr::height, cast(image.height()));
 
-    let mut css = css::Properties::build((engine, elem.span()));
+    let mut css = css::Properties::build(engine.binding_guard(elem.span()));
 
     // TODO: Exclude in semantic profile.
     if let Some(value) = typst_svg::convert_image_scaling(image.scaling()) {
diff --git a/crates/typst-ide/src/analyze.rs b/crates/typst-ide/src/analyze.rs
index 9d29357069e7..4c47f25d48d9 100644
--- a/crates/typst-ide/src/analyze.rs
+++ b/crates/typst-ide/src/analyze.rs
@@ -1,7 +1,7 @@
 use comemo::Track;
 use ecow::{EcoString, EcoVec, eco_vec};
 use rustc_hash::FxHashSet;
-use typst::foundations::{AsOutput, Label, Styles, Value};
+use typst::foundations::{AsOutput, Label, Styles, Value, WorldBindingExt};
 use typst::model::{BibliographyElem, FigureElem};
 use typst::syntax::{LinkedNode, SyntaxKind, ast};
 use typst_layout::PagedDocument;
@@ -62,12 +62,18 @@ pub fn analyze_expr_with_fallback(
     }
 
     let globals = crate::utils::globals(world, node);
+    let guard = world.silent_binding_guard();
     let value = match node.cast::()? {
-        ast::Expr::Ident(ident) => globals.get(&ident)?.read(),
+        ast::Expr::Ident(ident) => globals.get(&ident)?.read(&guard).ok()?,
         ast::Expr::FieldAccess(access) => match access.target() {
-            ast::Expr::Ident(target) => {
-                globals.get(&target)?.read().scope()?.get(&access.field())?.read()
-            }
+            ast::Expr::Ident(target) => globals
+                .get(&target)?
+                .read(&guard)
+                .ok()?
+                .scope()?
+                .get(&access.field())?
+                .read(&guard)
+                .ok()?,
             _ => return None,
         },
         _ => return None,
diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs
index ce1fc680d2db..4a4c464b2e99 100644
--- a/crates/typst-ide/src/complete.rs
+++ b/crates/typst-ide/src/complete.rs
@@ -6,7 +6,8 @@ use rustc_hash::FxHashSet;
 use serde::{Deserialize, Serialize};
 use typst::foundations::{
     AsOutput, AutoValue, CastInfo, Func, Label, NoneValue, Output, ParamInfo, Repr,
-    StyleChain, Styles, Type, Value, fields_on, repr,
+    SilentBindingGuard, StyleChain, Styles, Type, Value, WorldBindingExt, fields_on,
+    repr,
 };
 use typst::layout::{Alignment, Dir};
 use typst::syntax::ast::AstNode;
@@ -177,17 +178,20 @@ fn field_access_completions(
     // Autocomplete methods from the element's or type's scope. We only complete
     // those which have a `self` parameter.
     for (name, binding) in scopes.flat_map(|scope| scope.iter()) {
-        let Ok(func) = binding.read().clone().cast::() else { continue };
-        if let Some(param) = func.params().next()
+        if let Ok(value) = binding.read(ctx.binding_guard())
+            && let Ok(func) = value.clone().cast::()
+            && let Some(param) = func.params().next()
             && param.name() == Some("self")
         {
-            ctx.call_completion(name.clone(), binding.read());
+            ctx.call_completion(name.clone(), value);
         }
     }
 
     if let Some(scope) = value.scope() {
         for (name, binding) in scope.iter() {
-            ctx.call_completion(name.clone(), binding.read());
+            if let Ok(value) = binding.read(ctx.binding_guard()) {
+                ctx.call_completion(name.clone(), value);
+            }
         }
     }
 
@@ -197,7 +201,9 @@ fn field_access_completions(
         // with method syntax;
         // 2. We can unwrap the field's value since it's a field belonging to
         // this value's type, so accessing it should not fail.
-        ctx.value_completion(field, &value.field(field, ()).unwrap());
+        if let Ok(value) = value.field(field, ctx.binding_guard()) {
+            ctx.value_completion(field, &value);
+        }
     }
 
     match value {
@@ -322,8 +328,10 @@ fn import_item_completions<'a>(
     }
 
     for (name, binding) in scope.iter() {
-        if existing.iter().all(|item| item.original_name().as_str() != name) {
-            ctx.value_completion(name.clone(), binding.read());
+        if existing.iter().all(|item| item.original_name().as_str() != name)
+            && let Ok(value) = binding.read(ctx.binding_guard())
+        {
+            ctx.value_completion(name.clone(), value);
         }
     }
 }
@@ -1423,12 +1431,13 @@ impl<'a> CompletionContext<'a> {
     /// Add completions for definitions that are available at the cursor.
     ///
     /// Filters the global/math scope with the given filter.
-    fn scope_completions(&mut self, parens: bool, filter: impl Fn(&Value) -> bool) {
+    fn scope_completions(&mut self, parens: bool, filter_fn: impl Fn(&Value) -> bool) {
         // When any of the constituent parts of the value matches the filter,
         // that's ok as well. For example, when autocompleting `#rect(fill: |)`,
         // we propose colors, but also dictionaries and modules that contain
         // colors.
-        let filter = |value: &Value| check_value_recursively(value, &filter);
+        let guard = self.binding_guard();
+        let filter = |value: &Value| check_value_recursively(&guard, value, &filter_fn);
 
         let mut defined = BTreeMap::>::new();
         named_items(self.world, self.leaf.clone(), |item| {
@@ -1454,12 +1463,18 @@ impl<'a> CompletionContext<'a> {
         }
 
         for (name, binding) in globals(self.world, self.leaf).iter() {
-            let value = binding.read();
-            if filter(value) && !defined.contains_key(name) {
+            if let Ok(value) = binding.read(self.binding_guard())
+                && filter(value)
+                && !defined.contains_key(name)
+            {
                 self.value_completion_full(Some(name.clone()), value, parens, None, None);
             }
         }
     }
+
+    fn binding_guard(&self) -> SilentBindingGuard {
+        self.world.silent_binding_guard()
+    }
 }
 
 /// What kind of parentheses to autocomplete for a function.
diff --git a/crates/typst-ide/src/definition.rs b/crates/typst-ide/src/definition.rs
index d4f9fd51038a..5c729a967c8b 100644
--- a/crates/typst-ide/src/definition.rs
+++ b/crates/typst-ide/src/definition.rs
@@ -1,4 +1,4 @@
-use typst::foundations::{AsOutput, Label, Selector, Value};
+use typst::foundations::{AsOutput, Label, Selector, Value, WorldBindingExt};
 use typst::syntax::{FileId, LinkedNode, Side, Source, Span, ast};
 use typst::utils::PicoStr;
 
@@ -56,8 +56,10 @@ pub fn definition(
                 }
             }
 
-            if let Some(binding) = globals(world, &leaf).get(&name) {
-                return Some(Definition::Std(binding.read().clone()));
+            if let Some(binding) = globals(world, &leaf).get(&name)
+                && let Ok(value) = binding.read(world.silent_binding_guard())
+            {
+                return Some(Definition::Std(value.clone()));
             }
         }
 
diff --git a/crates/typst-ide/src/matchers.rs b/crates/typst-ide/src/matchers.rs
index eb20f290e394..4c95ff9d621c 100644
--- a/crates/typst-ide/src/matchers.rs
+++ b/crates/typst-ide/src/matchers.rs
@@ -1,5 +1,5 @@
 use ecow::EcoString;
-use typst::foundations::{Module, Value};
+use typst::foundations::{Module, Value, WorldBindingExt};
 use typst::syntax::ast::AstNode;
 use typst::syntax::{LinkedNode, Span, SyntaxKind, ast};
 
@@ -77,11 +77,14 @@ pub fn named_items(
                     Some(ast::Imports::Wildcard) => {
                         if let Some(scope) = source_value.and_then(Value::scope) {
                             for (name, binding) in scope.iter() {
-                                let item = NamedItem::Import(
-                                    name,
-                                    binding.span(),
-                                    Some(binding.read()),
-                                );
+                                let Ok(value) =
+                                    binding.read(world.silent_binding_guard())
+                                else {
+                                    continue;
+                                };
+
+                                let item =
+                                    NamedItem::Import(name, binding.span(), Some(value));
                                 if let Some(res) = recv(item) {
                                     return Some(res);
                                 }
@@ -101,13 +104,20 @@ pub fn named_items(
 
                             for ident in iter {
                                 binding = binding.and_then(|binding| {
-                                    binding.read().scope()?.get(&ident)
+                                    binding
+                                        .read(world.silent_binding_guard())
+                                        .ok()?
+                                        .scope()?
+                                        .get(&ident)
                                 });
                             }
 
                             let bound = item.bound_name();
                             let (span, value) = match binding {
-                                Some(binding) => (binding.span(), Some(binding.read())),
+                                Some(binding) => (
+                                    binding.span(),
+                                    binding.read(world.silent_binding_guard()).ok(),
+                                ),
                                 None => (bound.span(), None),
                             };
 
diff --git a/crates/typst-ide/src/utils.rs b/crates/typst-ide/src/utils.rs
index 2763d9e34760..c30634207dad 100644
--- a/crates/typst-ide/src/utils.rs
+++ b/crates/typst-ide/src/utils.rs
@@ -5,7 +5,7 @@ use comemo::Track;
 use ecow::EcoString;
 use indexmap::IndexMap;
 use typst::engine::{Engine, Route, Sink, Traced};
-use typst::foundations::{Scope, Value};
+use typst::foundations::{Scope, SilentBindingGuard, Value};
 use typst::introspection::EmptyIntrospector;
 use typst::syntax::{LinkedNode, SyntaxMode};
 use typst::text::{
@@ -184,10 +184,11 @@ pub fn globals<'a>(world: &'a dyn IdeWorld, leaf: &LinkedNode) -> &'a Scope {
 /// Checks whether the given value or any of its constituent parts satisfy the
 /// predicate.
 pub fn check_value_recursively(
+    guard: &SilentBindingGuard,
     value: &Value,
     predicate: impl Fn(&Value) -> bool,
 ) -> bool {
-    let mut searcher = Searcher { steps: 0, predicate, max_steps: 1000 };
+    let mut searcher = Searcher { guard, steps: 0, predicate, max_steps: 1000 };
     match searcher.find(value) {
         ControlFlow::Break(matching) => matching,
         ControlFlow::Continue(()) => false,
@@ -196,13 +197,14 @@ pub fn check_value_recursively(
 
 /// Recursively searches for a value that passes the filter, but without
 /// exceeding a maximum number of search steps.
-struct Searcher {
+struct Searcher<'a, F> {
+    guard: &'a SilentBindingGuard,
     max_steps: usize,
     steps: usize,
     predicate: F,
 }
 
-impl Searcher
+impl Searcher<'_, F>
 where
     F: Fn(&Value) -> bool,
 {
@@ -225,7 +227,13 @@ where
                 self.find_iter(content.fields().iter().map(|(_, v)| v))?;
             }
             Value::Module(module) => {
-                self.find_iter(module.scope().iter().map(|(_, b)| b.read()))?;
+                let guard = self.guard;
+                self.find_iter(
+                    module
+                        .scope()
+                        .iter()
+                        .filter_map(move |(_name, binding)| binding.read(guard).ok()),
+                )?;
             }
             _ => {}
         }
diff --git a/crates/typst-library/src/diag.rs b/crates/typst-library/src/diag.rs
index 197a57f72732..b8fe49685c0d 100644
--- a/crates/typst-library/src/diag.rs
+++ b/crates/typst-library/src/diag.rs
@@ -22,7 +22,6 @@ use typst_syntax::{
 };
 use utf8_iter::ErrorReportingUtf8Chars;
 
-use crate::engine::Engine;
 use crate::loading::{LoadSource, Loaded};
 use crate::{World, WorldExt};
 
@@ -160,6 +159,8 @@ macro_rules! __error {
 ///     hint[hint_span]: "hints can have custom spans and {}", "formatting";
 /// );
 /// ```
+///
+/// [`Engine`]: crate::engine::Engine
 #[macro_export]
 #[doc(hidden)]
 #[clippy::format_args]
@@ -414,17 +415,14 @@ pub trait WarningSink {
     fn emit(&mut self, message: HintedString);
 }
 
-impl WarningSink for () {
-    fn emit(&mut self, _: HintedString) {}
+impl WarningSink for &mut T {
+    fn emit(&mut self, message: HintedString) {
+        T::emit(self, message);
+    }
 }
 
-impl WarningSink for (&mut Engine<'_>, Span) {
-    fn emit(&mut self, hinted: HintedString) {
-        self.0.sink.warn(
-            SourceDiagnostic::warning(self.1, hinted.message())
-                .with_hints(hinted.hints().iter().cloned()),
-        );
-    }
+impl WarningSink for () {
+    fn emit(&mut self, _: HintedString) {}
 }
 
 /// A part of a diagnostic's [trace](SourceDiagnostic::trace).
diff --git a/crates/typst-library/src/engine.rs b/crates/typst-library/src/engine.rs
index 17d57d5e1621..f5fa9a89ed46 100644
--- a/crates/typst-library/src/engine.rs
+++ b/crates/typst-library/src/engine.rs
@@ -10,7 +10,7 @@ use typst_syntax::{FileId, Span};
 use typst_utils::{LazyHash, Protected};
 
 use crate::diag::{HintedStrResult, SourceDiagnostic, SourceResult, StrResult, bail};
-use crate::foundations::{Styles, Value};
+use crate::foundations::{NormalBindingGuard, Styles, Value};
 use crate::introspection::{Introspect, Introspection, Introspector};
 use crate::{Library, World};
 
@@ -35,7 +35,7 @@ pub struct Engine<'a> {
     pub route: Route<'a>,
 }
 
-impl Engine<'_> {
+impl<'a> Engine<'a> {
     /// Handles a result without immediately terminating execution. Instead, it
     /// produces a delayed error that is only promoted to a fatal one if it
     /// remains by the end of the introspection loop.
@@ -115,6 +115,11 @@ impl Engine<'_> {
         self.sink.introspection(Introspection::new(introspection));
         output
     }
+
+    /// Create a struct that implements [`crate::foundations::BindingGuard`].
+    pub fn binding_guard(&'_ mut self, span: Span) -> NormalBindingGuard<'_, 'a> {
+        NormalBindingGuard { engine: self, span }
+    }
 }
 
 /// May hold a span that is currently under inspection.
diff --git a/crates/typst-library/src/foundations/dict.rs b/crates/typst-library/src/foundations/dict.rs
index 7fb3db39bb39..d4d95f2c4f66 100644
--- a/crates/typst-library/src/foundations/dict.rs
+++ b/crates/typst-library/src/foundations/dict.rs
@@ -8,12 +8,13 @@ use ecow::{EcoString, eco_format};
 use indexmap::IndexMap;
 use rustc_hash::FxBuildHasher;
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
-use typst_syntax::is_ident;
+use typst_syntax::{Spanned, is_ident};
 
 use crate::diag::{At, Hint, HintedStrResult, SourceResult, StrResult};
 use crate::engine::Engine;
 use crate::foundations::{
-    Array, Context, Func, Module, Repr, Str, Value, array, cast, func, repr, scope, ty,
+    Array, Context, Func, Module, Repr, Str, Value, WorldBindingExt, array, cast, func,
+    repr, scope, ty,
 };
 
 /// Create a new [`Dict`] from key-value pairs.
@@ -183,10 +184,23 @@ impl Dict {
     /// ```
     #[func(constructor, since = "forever")]
     pub fn construct(
+        engine: &mut Engine,
         /// The value that should be converted to a dictionary.
-        value: ToDict,
-    ) -> Dict {
-        value.0
+        value: Spanned,
+    ) -> SourceResult {
+        let ToDict(module) = value.v;
+        let dict = module
+            .scope()
+            .iter()
+            .filter_map(|(key, binding)| {
+                // Filter out values that are in feature gated bindings and
+                // ignore any deprecation warnings that are emitted.
+                let guard = engine.world.silent_binding_guard();
+                let val = binding.read(guard).ok()?;
+                Some((Str::from(key.clone()), val.clone()))
+            })
+            .collect();
+        Ok(dict)
     }
 
     /// The number of pairs in the dictionary.
@@ -340,16 +354,14 @@ impl Dict {
 }
 
 /// A value that can be cast to dictionary.
-pub struct ToDict(Dict);
+/// Currently only modules are supported, and the conversion is deferred because
+/// when accessing the scope of the module the list of active features needs to
+/// be provided.
+pub struct ToDict(Module);
 
 cast! {
     ToDict,
-    v: Module => Self(v
-        .scope()
-        .iter()
-        .map(|(k, b)| (Str::from(k.clone()), b.read().clone()))
-        .collect()
-    ),
+    m: Module => Self(m),
 }
 
 impl Debug for Dict {
diff --git a/crates/typst-library/src/foundations/func.rs b/crates/typst-library/src/foundations/func.rs
index 3a856e44b19d..1549f4f2441c 100644
--- a/crates/typst-library/src/foundations/func.rs
+++ b/crates/typst-library/src/foundations/func.rs
@@ -11,11 +11,11 @@ use either::Either;
 use typst_syntax::{Span, Spanned, SyntaxNode, ast};
 use typst_utils::{DefSite, LazyHash, Static, singleton};
 
-use crate::diag::{At, SourceResult, StrResult, WarningSink, bail};
+use crate::diag::{At, HintedStrResult, SourceResult, StrResult, bail};
 use crate::engine::Engine;
 use crate::foundations::{
-    Args, Bytes, CastInfo, Content, Context, Element, IntoArgs, PluginFunc, Repr, Scope,
-    Selector, Since, Type, Value, cast, scope, ty,
+    Args, BindingAccess, BindingGuard, Bytes, CastInfo, Content, Context, Element,
+    IntoArgs, PluginFunc, Repr, Scope, Selector, Since, Type, Value, cast, scope, ty,
 };
 
 /// A mapping from argument values to a return value.
@@ -291,12 +291,14 @@ impl Func {
     pub fn field(
         &self,
         field: &str,
-        sink: impl WarningSink,
-    ) -> StrResult<&'static Value> {
+        guard: impl BindingGuard,
+    ) -> HintedStrResult<&'static Value> {
         let scope =
             self.scope().ok_or("cannot access fields on user-defined functions")?;
         match scope.get(field) {
-            Some(binding) => Ok(binding.read_checked(sink)),
+            Some(binding) => {
+                binding.read(guard).or_cannot(format_args!("access field `{field}`"))
+            }
             None => match self.name() {
                 Some(name) => bail!("function `{name}` does not contain field `{field}`"),
                 None => bail!("function does not contain field `{field}`"),
diff --git a/crates/typst-library/src/foundations/module.rs b/crates/typst-library/src/foundations/module.rs
index 5b006af6e002..89a2d42aed5a 100644
--- a/crates/typst-library/src/foundations/module.rs
+++ b/crates/typst-library/src/foundations/module.rs
@@ -4,8 +4,8 @@ use std::sync::Arc;
 use ecow::{EcoString, eco_format};
 use typst_syntax::FileId;
 
-use crate::diag::{StrResult, WarningSink, bail};
-use crate::foundations::{Content, Repr, Scope, Value, ty};
+use crate::diag::{HintedStrResult, bail};
+use crate::foundations::{BindingAccess, BindingGuard, Content, Repr, Scope, Value, ty};
 
 /// A collection of variables and functions that are commonly related to a
 /// single theme.
@@ -136,9 +136,15 @@ impl Module {
     }
 
     /// Try to access a definition in the module.
-    pub fn field(&self, field: &str, sink: impl WarningSink) -> StrResult<&Value> {
+    pub fn field(
+        &self,
+        field: &str,
+        guard: impl BindingGuard,
+    ) -> HintedStrResult<&Value> {
         match self.scope().get(field) {
-            Some(binding) => Ok(binding.read_checked(sink)),
+            Some(binding) => {
+                binding.read(guard).or_cannot(format_args!("access field `{field}`"))
+            }
             None => match &self.name {
                 Some(name) => bail!("module `{name}` does not contain `{field}`"),
                 None => bail!("module does not contain `{field}`"),
diff --git a/crates/typst-library/src/foundations/ops.rs b/crates/typst-library/src/foundations/ops.rs
index 0aed5af4f3e6..7e72a61aac94 100644
--- a/crates/typst-library/src/foundations/ops.rs
+++ b/crates/typst-library/src/foundations/ops.rs
@@ -7,7 +7,7 @@ use typst_utils::Numeric;
 
 use crate::diag::{HintedStrResult, StrResult, bail};
 use crate::foundations::{
-    Datetime, IntoValue, Regex, Repr, SymbolElem, Value, format_str,
+    BindingGuard, Datetime, IntoValue, Regex, Repr, SymbolElem, Value, format_str,
 };
 use crate::layout::{Alignment, Length, Rel};
 use crate::text::TextElem;
@@ -534,8 +534,8 @@ fn try_cmp_arrays(a: &[Value], b: &[Value]) -> StrResult {
 }
 
 /// Test whether one value is "in" another one.
-pub fn in_(lhs: Value, rhs: Value) -> HintedStrResult {
-    if let Some(b) = contains(&lhs, &rhs) {
+pub fn in_(guard: impl BindingGuard, lhs: Value, rhs: Value) -> HintedStrResult {
+    if let Some(b) = contains(guard, &lhs, &rhs) {
         Ok(Value::Bool(b))
     } else {
         mismatch!("cannot apply 'in' to {} and {}", lhs, rhs)
@@ -543,8 +543,12 @@ pub fn in_(lhs: Value, rhs: Value) -> HintedStrResult {
 }
 
 /// Test whether one value is "not in" another one.
-pub fn not_in(lhs: Value, rhs: Value) -> HintedStrResult {
-    if let Some(b) = contains(&lhs, &rhs) {
+pub fn not_in(
+    guard: impl BindingGuard,
+    lhs: Value,
+    rhs: Value,
+) -> HintedStrResult {
+    if let Some(b) = contains(guard, &lhs, &rhs) {
         Ok(Value::Bool(!b))
     } else {
         mismatch!("cannot apply 'not in' to {} and {}", lhs, rhs)
@@ -552,13 +556,15 @@ pub fn not_in(lhs: Value, rhs: Value) -> HintedStrResult {
 }
 
 /// Test for containment.
-pub fn contains(lhs: &Value, rhs: &Value) -> Option {
+pub fn contains(guard: impl BindingGuard, lhs: &Value, rhs: &Value) -> Option {
     use Value::*;
     match (lhs, rhs) {
         (Str(a), Str(b)) => Some(b.as_str().contains(a.as_str())),
         (Dyn(a), Str(b)) => a.downcast::().map(|regex| regex.is_match(b)),
         (Str(a), Dict(b)) => Some(b.contains(a)),
-        (Str(a), Module(b)) => Some(b.scope().get(a).is_some()),
+        (Str(a), Module(b)) => {
+            Some(b.scope().get(a).is_some_and(|b| b.read(guard).is_ok()))
+        }
         (a, Array(b)) => Some(b.contains(a.clone())),
 
         _ => Option::None,
diff --git a/crates/typst-library/src/foundations/scope.rs b/crates/typst-library/src/foundations/scope.rs
index 14d2768185ba..b17d9d60666d 100644
--- a/crates/typst-library/src/foundations/scope.rs
+++ b/crates/typst-library/src/foundations/scope.rs
@@ -1,4 +1,4 @@
-use std::fmt::{self, Debug, Formatter};
+use std::fmt::{self, Debug, Display, Formatter};
 use std::hash::{Hash, Hasher};
 
 use ecow::{EcoString, eco_format};
@@ -7,11 +7,12 @@ use indexmap::map::Entry;
 use rustc_hash::FxBuildHasher;
 use typst_syntax::Span;
 
-use crate::diag::{HintedStrResult, HintedString, StrResult, WarningSink, bail};
+use crate::diag::{HintedStrResult, HintedString, SourceDiagnostic, WarningSink, error};
+use crate::engine::Engine;
 use crate::foundations::{
     Func, IntoValue, NativeElement, NativeFunc, NativeFuncData, NativeType, Value,
 };
-use crate::{Category, Library};
+use crate::{Category, Feature, Features, Library, World};
 
 /// A stack of scopes.
 #[derive(Debug, Default, Clone)]
@@ -42,8 +43,26 @@ impl<'a> Scopes<'a> {
         self.top = self.scopes.pop().expect("no pushed scope");
     }
 
-    /// Try to access a binding immutably.
-    pub fn get(&self, var: &str) -> HintedStrResult<&Binding> {
+    /// Try to access a binding value immutably.
+    pub fn get(&self, var: &str, guard: impl BindingGuard) -> HintedStrResult<&Value> {
+        self.get_binding(var)?
+            .read(guard)
+            .or_cannot(format_args!("access variable `{var}`"))
+    }
+
+    /// Try to access a binding value immutably in math.
+    pub fn get_in_math(
+        &self,
+        var: &str,
+        guard: impl BindingGuard,
+    ) -> HintedStrResult<&Value> {
+        self.get_binding_in_math(var)?
+            .read(guard)
+            .or_cannot(format_args!("access variable `{var}`"))
+    }
+
+    /// Try to access a binding.
+    pub fn get_binding(&self, var: &str) -> HintedStrResult<&Binding> {
         std::iter::once(&self.top)
             .chain(self.scopes.iter().rev())
             .find_map(|scope| scope.get(var))
@@ -58,22 +77,8 @@ impl<'a> Scopes<'a> {
             .ok_or_else(|| unknown_variable(var))
     }
 
-    /// Try to access a binding mutably.
-    pub fn get_mut(&mut self, var: &str) -> HintedStrResult<&mut Binding> {
-        std::iter::once(&mut self.top)
-            .chain(&mut self.scopes.iter_mut().rev())
-            .find_map(|scope| scope.get_mut(var))
-            .ok_or_else(|| {
-                match self.base.and_then(|base| base.global.scope().get(var)) {
-                    Some(_) => cannot_mutate_constant(var),
-                    _ if var == "std" => cannot_mutate_constant(var),
-                    _ => unknown_variable(var),
-                }
-            })
-    }
-
-    /// Try to access a binding immutably in math.
-    pub fn get_in_math(&self, var: &str) -> HintedStrResult<&Binding> {
+    /// Try to access a binding in math.
+    pub fn get_binding_in_math(&self, var: &str) -> HintedStrResult<&Binding> {
         std::iter::once(&self.top)
             .chain(self.scopes.iter().rev())
             .find_map(|scope| scope.get(var))
@@ -93,6 +98,35 @@ impl<'a> Scopes<'a> {
             })
     }
 
+    /// Try to access a binding mutably.
+    pub fn get_mut(
+        &mut self,
+        var: &str,
+        mut guard: impl BindingGuard,
+    ) -> HintedStrResult<&mut Value> {
+        std::iter::once(&mut self.top)
+            .chain(&mut self.scopes.iter_mut().rev())
+            .find_map(|scope| {
+                Some(
+                    scope
+                        .get_mut(var)?
+                        .write(&mut guard)
+                        .or_cannot(format_args!("access variable `{var}`")),
+                )
+            })
+            .transpose()?
+            .ok_or_else(|| {
+                match self.base.and_then(|base| base.global.scope().get(var)) {
+                    Some(binding) => match binding.read(guard) {
+                        Err(err) => err.cannot(format_args!("access variable `{var}`")),
+                        _ => cannot_mutate_constant(var),
+                    },
+                    _ if var == "std" => cannot_mutate_constant(var),
+                    _ => unknown_variable(var),
+                }
+            })
+    }
+
     /// Check if an std variable is shadowed.
     pub fn check_std_shadowed(&self, var: &str) -> bool {
         self.base.is_some_and(|base| base.global.scope().get(var).is_some())
@@ -180,7 +214,7 @@ impl Scope {
         }
 
         let mut binding = Binding::detached(value);
-        binding.category = self.category;
+        binding.init_info().category = self.category;
         self.bind(name.into(), binding)
     }
 }
@@ -201,7 +235,7 @@ impl Scope {
     }
 
     /// Try to access a binding immutably.
-    pub fn get(&self, var: &str) -> Option<&Binding> {
+    pub fn get<'a>(&'a self, var: &str) -> Option<&'a Binding> {
         self.map.get(var)
     }
 
@@ -220,7 +254,7 @@ impl Debug for Scope {
     fn fmt(&self, f: &mut Formatter) -> fmt::Result {
         f.write_str("Scope ")?;
         f.debug_map()
-            .entries(self.map.iter().map(|(k, v)| (k, v.read())))
+            .entries(self.map.iter().map(|(k, v)| (k, &v.value)))
             .finish()
     }
 }
@@ -254,10 +288,15 @@ pub struct Binding {
     kind: BindingKind,
     /// A span associated with the binding.
     span: Span,
-    /// The category of the binding.
-    category: Option,
-    /// The deprecation information if this item is deprecated.
-    deprecation: Option>,
+    /// Whether this binding has additional checks that should be performed
+    /// before accessing it. This boolean is just an optimization, so the happy
+    /// path stays fast. The concrete checks are performed on the properties of
+    /// additional binding information in [`Self::info`].
+    check_access: bool,
+    /// Infrequently accessed properties of the binding that are stored out of
+    /// band. These should only ever be set for built-in bindings in the
+    /// standard library.
+    info: Option>,
 }
 
 /// The different kinds of slots.
@@ -276,8 +315,8 @@ impl Binding {
             value: value.into_value(),
             span,
             kind: BindingKind::Normal,
-            category: None,
-            deprecation: None,
+            check_access: false,
+            info: None,
         }
     }
 
@@ -286,44 +325,90 @@ impl Binding {
         Self::new(value, Span::detached())
     }
 
+    /// Sets the category of this binding.
+    pub fn with_category(&mut self, category: Category) -> &mut Self {
+        self.init_info().category = Some(category);
+        self
+    }
+
+    /// Gates this binding behind the given [`Feature`].
+    pub fn with_feature(&mut self, feature: Feature) -> &mut Self {
+        let info = self.init_info();
+        info.feature = Some(feature);
+        self.check_access = info.has_checked_access();
+        self
+    }
+
     /// Marks this binding as deprecated, with the given `message`.
-    pub fn deprecated(&mut self, deprecation: Deprecation) -> &mut Self {
-        self.deprecation = Some(Box::new(deprecation));
+    pub fn with_deprecation(&mut self, deprecation: Deprecation) -> &mut Self {
+        let info = self.init_info();
+        info.deprecation = Some(deprecation);
+        self.check_access = info.has_checked_access();
         self
     }
 
-    /// Read the value.
-    pub fn read(&self) -> &Value {
-        &self.value
+    fn init_info(&mut self) -> &mut BindingInfo {
+        self.info.get_or_insert_default()
     }
 
-    /// Read the value, checking for deprecation.
+    /// Try to read the binding value.
     ///
-    /// As the `sink`
-    /// - pass `()` to ignore the message.
-    /// - pass `(&mut engine, span)` to emit a warning into the engine.
-    pub fn read_checked(&self, mut sink: impl WarningSink) -> &Value {
-        if let Some(message) = &self.deprecation {
-            sink.emit((**message).into());
+    /// The guard is used to check for deprecation and feature gates.
+    ///
+    /// # Example
+    ///
+    /// ```
+    /// use typst_library::foundations::{Binding, BindingAccess, Value};
+    /// use typst_library::diag::{At, SourceResult};
+    /// use typst_library::engine::Engine;
+    /// use typst_syntax::Span;
+    ///
+    /// fn read_var(binding: &Binding, engine: &mut Engine, span: Span) -> SourceResult {
+    ///     binding.read(engine.binding_guard(span))
+    ///         .or_cannot("access variable")
+    ///         .at(span)
+    ///         .cloned()
+    /// }
+    /// ```
+    pub fn read(&self, guard: impl BindingGuard) -> Result<&Value, FeatureError> {
+        if self.check_access {
+            self.check_access(guard)?;
         }
-        &self.value
+        Ok(&self.value)
     }
 
-    /// Try to write to the value.
-    ///
-    /// This fails if the value is a read-only closure capture.
-    pub fn write(&mut self) -> StrResult<&mut Value> {
-        match self.kind {
-            BindingKind::Normal => Ok(&mut self.value),
-            BindingKind::Captured(capturer) => bail!(
-                "variables from outside the {} are \
-                 read-only and cannot be modified",
-                match capturer {
-                    Capturer::Function => "function",
-                    Capturer::Context => "context expression",
-                },
-            ),
+    /// Try to write to the binding.
+    pub fn write(
+        &mut self,
+        guard: impl BindingGuard,
+    ) -> Result<&mut Value, BindingError> {
+        if self.check_access {
+            self.check_access(guard)?;
+        }
+
+        if let BindingKind::Captured(capturer) = self.kind {
+            return Err(BindingError::Captured(capturer));
         }
+
+        Ok(&mut self.value)
+    }
+
+    /// Check if the binding is gated behind a feature or if it is deprecated.
+    #[cold]
+    fn check_access(&self, mut guard: impl BindingGuard) -> Result<(), FeatureError> {
+        let Some(info) = &self.info else { return Ok(()) };
+
+        if let Some(feature) = info.feature
+            && !guard.features().is_enabled(feature)
+        {
+            return Err(FeatureError(feature));
+        }
+
+        if let Some(message) = info.deprecation {
+            guard.emit(message.into());
+        }
+
+        Ok(())
     }
 
     /// Create a copy of the binding for closure capturing.
@@ -339,14 +424,112 @@ impl Binding {
         self.span
     }
 
-    /// A deprecation message for the value, if any.
-    pub fn deprecation(&self) -> Option<&Deprecation> {
-        self.deprecation.as_deref()
+    /// The category of the binding, if any.
+    pub fn category(&self) -> Option {
+        self.info.as_ref()?.category
+    }
+
+    /// The feature that gates the binding, if any.
+    pub fn feature(&self) -> Option {
+        self.info.as_ref()?.feature
     }
 
-    /// The category of the value, if any.
-    pub fn category(&self) -> Option {
-        self.category
+    /// The deprecation of the binding, if any.
+    pub fn deprecation(&self) -> Option {
+        self.info.as_ref()?.deprecation
+    }
+}
+
+/// Infrequently accessed information related to a binding.
+#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)]
+struct BindingInfo {
+    /// The category of the binding.
+    category: Option,
+    /// A feature required to access this binding.
+    feature: Option,
+    /// The deprecation information if this item is deprecated.
+    deprecation: Option,
+}
+
+impl BindingInfo {
+    /// Whether the binding info has a property set that requires checking
+    /// it when it's accessed.
+    fn has_checked_access(self) -> bool {
+        self.feature.is_some() || self.deprecation.is_some()
+    }
+}
+
+/// There was an error accessing a binding.
+///
+/// A [`Result`] can be converted into a [`HintedStrResult`]
+/// using the [`BindingAccess`] trait, see [`Binding::read`].
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub enum BindingError {
+    /// The binding cannot be written to, because it is captured.
+    Captured(Capturer),
+    /// The feature that gates it isn't enabled.
+    ///
+    /// A [`Result`] can be converted into a
+    /// [`HintedStrResult`] using the [`BindingAccess`] trait, see
+    /// [`Binding::read`].
+    Feature(FeatureError),
+}
+
+impl BindingError {
+    pub fn cannot(self, what: impl Display) -> HintedString {
+        match self {
+            BindingError::Captured(capturer) => {
+                error!(
+                    "variables from outside the {capturer} are \
+                     read-only and cannot be modified",
+                )
+            }
+            BindingError::Feature(error) => error.cannot(what),
+        }
+    }
+}
+
+impl From for BindingError {
+    fn from(v: FeatureError) -> Self {
+        Self::Feature(v)
+    }
+}
+
+/// There was an error accessing a binding.
+///
+/// A [`Result`] can be converted into a [`HintedStrResult`]
+/// using the [`BindingAccess`] trait, see [`Binding::read`].
+#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
+pub struct FeatureError(Feature);
+
+impl FeatureError {
+    pub fn cannot(self, what: impl Display) -> HintedString {
+        let Self(feature) = self;
+        error!(
+            "cannot {what} because the `{feature}` feature is not enabled";
+            // TODO: Support for CLI-specific hints would be nice.
+            hint: "try enabling the `{feature}` feature";
+            hint: "see https://typst.app/help/compiler-features for more details";
+        )
+    }
+}
+
+/// Convert a [`BindingError`] to a [`HintedStrResult`] by providing a
+/// description of what kind of binding couldn't be accessed.
+pub trait BindingAccess {
+    /// Add a description of what kind of binding couldn't be accessed.
+    fn or_cannot(self, what: impl Display) -> HintedStrResult;
+}
+
+impl BindingAccess for Result {
+    fn or_cannot(self, what: impl Display) -> HintedStrResult {
+        self.map_err(|err| err.cannot(what))
+    }
+}
+
+impl BindingAccess for Result {
+    fn or_cannot(self, what: impl Display) -> HintedStrResult {
+        self.map_err(|err| err.cannot(what))
     }
 }
 
@@ -359,6 +542,15 @@ pub enum Capturer {
     Context,
 }
 
+impl Display for Capturer {
+    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
+        f.write_str(match self {
+            Capturer::Function => "function",
+            Capturer::Context => "context expression",
+        })
+    }
+}
+
 /// Information about a deprecated binding.
 #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
 pub struct Deprecation {
@@ -424,7 +616,7 @@ fn cannot_mutate_constant(var: &str) -> HintedString {
 /// The error message when a variable wasn't found.
 #[cold]
 fn unknown_variable(var: &str) -> HintedString {
-    let mut res = HintedString::new(eco_format!("unknown variable: {var}"));
+    let mut res = HintedString::new(eco_format!("unknown variable `{var}`"));
 
     if var.contains('-') {
         res.hint(eco_format!(
@@ -441,7 +633,7 @@ fn unknown_variable(var: &str) -> HintedString {
 /// The error message when a variable wasn't found it math.
 #[cold]
 fn unknown_variable_math(var: &str, in_global: bool) -> HintedString {
-    let mut res = HintedString::new(eco_format!("unknown variable: {var}"));
+    let mut res = HintedString::new(eco_format!("unknown variable `{var}`"));
 
     if matches!(var, "none" | "auto" | "false" | "true") {
         res.hint(eco_format!(
@@ -472,3 +664,90 @@ fn unknown_variable_math(var: &str, in_global: bool) -> HintedString {
 
     res
 }
+
+/// Provides the currently enabled features when reading from a [`Binding`].
+pub trait BindingGuard: WarningSink {
+    /// The features enabled in the current [`crate::Library`].
+    fn features(&self) -> &Features;
+
+    /// Creates a [`BindingGuard`] that discards emitted warnings.
+    fn silent(&self) -> SilentBindingGuard {
+        SilentBindingGuard::new(self.features().clone())
+    }
+}
+
+impl BindingGuard for &mut T {
+    fn features(&self) -> &Features {
+        T::features(self)
+    }
+}
+
+/// Create a [`BindingGuard`] from a [`World`]'s library, that discards all
+/// emitted warnings.
+pub trait WorldBindingExt {
+    /// Create a [`BindingGuard`] that discards emitted warnings.
+    fn silent_binding_guard(&self) -> SilentBindingGuard;
+}
+
+impl WorldBindingExt for T {
+    fn silent_binding_guard(&self) -> SilentBindingGuard {
+        SilentBindingGuard { features: self.library().features.clone() }
+    }
+}
+
+/// A [`BindingGuard`] that emits warnings to the engine's sink.
+pub struct NormalBindingGuard<'x, 'y> {
+    pub engine: &'x mut Engine<'y>,
+    pub span: Span,
+}
+
+impl WarningSink for NormalBindingGuard<'_, '_> {
+    fn emit(&mut self, message: HintedString) {
+        self.engine.sink.warn(
+            SourceDiagnostic::warning(self.span, message.message())
+                .with_hints(message.hints().iter().cloned()),
+        );
+    }
+}
+
+impl BindingGuard for NormalBindingGuard<'_, '_> {
+    fn features(&self) -> &Features {
+        &self.engine.library.features
+    }
+}
+
+/// A [`BindingGuard`] that discards emitted warnings.
+#[derive(Clone)]
+pub struct SilentBindingGuard {
+    features: Features,
+}
+
+impl SilentBindingGuard {
+    pub fn new(features: Features) -> Self {
+        Self { features }
+    }
+}
+
+impl WarningSink for SilentBindingGuard {
+    fn emit(&mut self, _message: HintedString) {
+        // Just discard warnings.
+    }
+}
+
+impl WarningSink for &SilentBindingGuard {
+    fn emit(&mut self, _message: HintedString) {
+        // Just discard warnings.
+    }
+}
+
+impl BindingGuard for SilentBindingGuard {
+    fn features(&self) -> &Features {
+        &self.features
+    }
+}
+
+impl BindingGuard for &SilentBindingGuard {
+    fn features(&self) -> &Features {
+        &self.features
+    }
+}
diff --git a/crates/typst-library/src/foundations/ty.rs b/crates/typst-library/src/foundations/ty.rs
index 4b61d694b203..2bc3a1bb8e64 100644
--- a/crates/typst-library/src/foundations/ty.rs
+++ b/crates/typst-library/src/foundations/ty.rs
@@ -8,9 +8,10 @@ use std::sync::LazyLock;
 use ecow::{EcoString, eco_format};
 use typst_utils::{DefSite, Static};
 
-use crate::diag::{StrResult, WarningSink, bail};
+use crate::diag::{HintedStrResult, StrResult, bail};
 use crate::foundations::{
-    AutoValue, Func, NativeFuncData, NoneValue, Repr, Scope, Since, Value, cast, func,
+    AutoValue, BindingAccess, BindingGuard, Func, NativeFuncData, NoneValue, Repr, Scope,
+    Since, Value, cast, func,
 };
 
 /// Describes a kind of value.
@@ -123,10 +124,12 @@ impl Type {
     pub fn field(
         &self,
         field: &str,
-        sink: impl WarningSink,
-    ) -> StrResult<&'static Value> {
+        guard: impl BindingGuard,
+    ) -> HintedStrResult<&'static Value> {
         match self.scope().get(field) {
-            Some(binding) => Ok(binding.read_checked(sink)),
+            Some(binding) => binding
+                .read(guard)
+                .or_cannot(format_args!("access field `{field}` on type {self}")),
             None => bail!("type {self} does not contain field `{field}`"),
         }
     }
diff --git a/crates/typst-library/src/foundations/value.rs b/crates/typst-library/src/foundations/value.rs
index 7c4942447063..0282c46c6277 100644
--- a/crates/typst-library/src/foundations/value.rs
+++ b/crates/typst-library/src/foundations/value.rs
@@ -10,12 +10,12 @@ use serde::de::{Error, MapAccess, SeqAccess, Visitor};
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
 use typst_syntax::{Span, ast};
 
-use crate::diag::{HintedStrResult, HintedString, StrResult, WarningSink};
+use crate::diag::{HintedStrResult, HintedString};
 use crate::foundations::{
-    Args, Array, AutoValue, Bytes, CastInfo, Content, Datetime, Decimal, Dict, Duration,
-    Fold, FromValue, Func, IntoValue, Label, Module, NativeElement, NativeType,
-    NoneValue, Reflect, Repr, Resolve, Scope, Str, Styles, Symbol, SymbolElem, Type,
-    Version, fields, ops, repr,
+    Args, Array, AutoValue, BindingGuard, Bytes, CastInfo, Content, Datetime, Decimal,
+    Dict, Duration, Fold, FromValue, Func, IntoValue, Label, Module, NativeElement,
+    NativeType, NoneValue, Reflect, Repr, Resolve, Scope, Str, Styles, Symbol,
+    SymbolElem, Type, Version, fields, ops, repr,
 };
 use crate::layout::{Abs, Angle, Em, Fr, Length, Ratio, Rel};
 use crate::text::{RawContent, RawElem, TextElem};
@@ -154,20 +154,18 @@ impl Value {
     }
 
     /// Try to access a field on the value.
-    pub fn field(&self, field: &str, sink: impl WarningSink) -> StrResult {
-        match self {
-            Self::Symbol(symbol) => {
-                symbol.clone().modified(sink, field).map(Self::Symbol)
-            }
-            Self::Version(version) => version.component(field).map(Self::Int),
-            Self::Dict(dict) => dict.get(field).cloned(),
-            Self::Args(args) => args.field(field).cloned(),
-            Self::Content(content) => content.field_by_name(field),
-            Self::Type(ty) => ty.field(field, sink).cloned(),
-            Self::Func(func) => func.field(field, sink).cloned(),
-            Self::Module(module) => module.field(field, sink).cloned(),
-            _ => fields::field(self, field),
-        }
+    pub fn field(&self, field: &str, guard: impl BindingGuard) -> HintedStrResult {
+        Ok(match self {
+            Self::Symbol(symbol) => Self::Symbol(symbol.clone().modified(guard, field)?),
+            Self::Version(version) => Self::Int(version.component(field)?),
+            Self::Dict(dict) => dict.get(field)?.clone(),
+            Self::Args(args) => args.field(field)?.clone(),
+            Self::Content(content) => content.field_by_name(field)?,
+            Self::Type(ty) => ty.field(field, guard)?.clone(),
+            Self::Func(func) => func.field(field, guard)?.clone(),
+            Self::Module(module) => module.field(field, guard)?.clone(),
+            _ => fields::field(self, field)?,
+        })
     }
 
     /// The associated scope, if this is a function, type, or module.
diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs
index 21d733e0016d..992fe4ab5a07 100644
--- a/crates/typst-library/src/lib.rs
+++ b/crates/typst-library/src/lib.rs
@@ -26,7 +26,9 @@ pub mod symbols;
 pub mod text;
 pub mod visualize;
 
+use std::fmt::Display;
 use std::ops::{Deref, Range};
+use std::str::FromStr;
 
 use serde::{Deserialize, Serialize};
 use typst_syntax::{DiagSpan, DiagSpanKind, FileId, Source};
@@ -221,7 +223,7 @@ impl LibraryBuilder {
     pub fn build(self) -> Library {
         let math = math::module();
         let inputs = self.inputs.unwrap_or_default();
-        let global = global(self.routines, math.clone(), inputs, &self.features);
+        let global = global(self.routines, math.clone(), inputs);
         Library {
             routines: self.routines,
             global: global.clone(),
@@ -237,7 +239,7 @@ impl LibraryBuilder {
 /// A selection of in-development features that should be enabled.
 ///
 /// Can be collected from an iterator of [`Feature`]s.
-#[derive(Debug, Default, Clone, Hash)]
+#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)]
 pub struct Features(SmallBitSet);
 
 impl Features {
@@ -283,6 +285,29 @@ impl Feature {
     }
 }
 
+impl Display for Feature {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.write_str(match self {
+            Feature::Html => "html",
+            Feature::Bundle => "bundle",
+            Feature::A11yExtras => "a11y-extras",
+        })
+    }
+}
+
+impl FromStr for Feature {
+    type Err = ();
+
+    fn from_str(s: &str) -> Result {
+        match s {
+            "html" => Ok(Self::Html),
+            "bundle" => Ok(Self::Bundle),
+            "a11y-extras" => Ok(Self::A11yExtras),
+            _ => Err(()),
+        }
+    }
+}
+
 /// A group of related standard library definitions.
 #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
 #[serde(rename_all = "kebab-case")]
@@ -326,16 +351,11 @@ impl Category {
 }
 
 /// Construct the module with global definitions.
-fn global(
-    routines: &Routines,
-    math: Module,
-    inputs: Dict,
-    features: &Features,
-) -> Module {
+fn global(routines: &Routines, math: Module, inputs: Dict) -> Module {
     let mut global = Scope::deduplicating();
 
     self::foundations::define(&mut global, inputs);
-    self::model::define(&mut global, features);
+    self::model::define(&mut global);
     self::text::define(&mut global);
     self::layout::define(&mut global);
     self::visualize::define(&mut global);
@@ -344,10 +364,10 @@ fn global(
     self::symbols::define(&mut global);
 
     global.define("math", math);
-    global.define("pdf", self::pdf::module(features));
-    if features.is_enabled(Feature::Html) {
-        global.define("html", (routines.html_module)());
-    }
+    global.define("pdf", self::pdf::module());
+    global
+        .define("html", (routines.html_module)())
+        .with_feature(Feature::Html);
 
     prelude(&mut global);
 
diff --git a/crates/typst-library/src/model/mod.rs b/crates/typst-library/src/model/mod.rs
index 15f3195c6f4a..459ec4c5331f 100644
--- a/crates/typst-library/src/model/mod.rs
+++ b/crates/typst-library/src/model/mod.rs
@@ -46,16 +46,14 @@ pub use self::table::*;
 pub use self::terms::*;
 pub use self::title::*;
 
+use crate::Feature;
 use crate::foundations::Scope;
-use crate::{Feature, Features};
 
 /// Hook up all `model` definitions.
-pub fn define(global: &mut Scope, features: &Features) {
+pub fn define(global: &mut Scope) {
     global.start_category(crate::Category::Model);
     global.define_elem::();
-    if features.is_enabled(Feature::Bundle) {
-        global.define_elem::();
-    }
+    global.define_elem::().with_feature(Feature::Bundle);
     global.define_elem::();
     global.define_elem::();
     global.define_elem::();
diff --git a/crates/typst-library/src/pdf/mod.rs b/crates/typst-library/src/pdf/mod.rs
index 7dcdb9283ea1..86eb7ad1319f 100644
--- a/crates/typst-library/src/pdf/mod.rs
+++ b/crates/typst-library/src/pdf/mod.rs
@@ -6,19 +6,19 @@ mod attach;
 pub use self::accessibility::*;
 pub use self::attach::*;
 
+use crate::Feature;
 use crate::foundations::{Module, Scope};
-use crate::{Feature, Features};
 
 /// Hook up all `pdf` definitions.
-pub fn module(features: &Features) -> Module {
+pub fn module() -> Module {
     let mut pdf = Scope::deduplicating();
     pdf.start_category(crate::Category::Pdf);
     pdf.define_elem::();
     pdf.define_elem::();
-    if features.is_enabled(Feature::A11yExtras) {
-        pdf.define_func::();
-        pdf.define_func::();
-        pdf.define_func::();
-    }
+
+    pdf.define_func::().with_feature(Feature::A11yExtras);
+    pdf.define_func::().with_feature(Feature::A11yExtras);
+    pdf.define_func::().with_feature(Feature::A11yExtras);
+
     Module::new("pdf", pdf)
 }
diff --git a/crates/typst-library/src/symbols.rs b/crates/typst-library/src/symbols.rs
index 3cb0c15ee66b..ca5ea7d1ed43 100644
--- a/crates/typst-library/src/symbols.rs
+++ b/crates/typst-library/src/symbols.rs
@@ -23,7 +23,7 @@ fn extend_scope_from_codex_module(scope: &mut Scope, module: codex::Module) {
 
         let scope_binding = scope.define(name, value);
         if let Some(message) = binding.deprecation {
-            scope_binding.deprecated(Deprecation::new().with_message(message));
+            scope_binding.with_deprecation(Deprecation::new().with_message(message));
         }
     }
 }
diff --git a/crates/typst-macros/src/scope.rs b/crates/typst-macros/src/scope.rs
index 6e623ff76e5a..d2a32c465839 100644
--- a/crates/typst-macros/src/scope.rs
+++ b/crates/typst-macros/src/scope.rs
@@ -56,19 +56,17 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result {
         };
 
         if let Some(attr) = attrs.iter().find(|attr| attr.path().is_ident("deprecated")) {
+            let mut deprecation = quote! { #foundations::Deprecation::new() };
             match &attr.meta {
-                syn::Meta::NameValue(pair) if pair.path.is_ident("deprecated") => {
+                syn::Meta::NameValue(pair) => {
                     let message = &pair.value;
-                    def = quote! { #def.deprecated(#message) }
+                    deprecation = quote! { #deprecation.with_message(#message) }
                 }
-                syn::Meta::List(list) if list.path.is_ident("deprecated") => {
+                syn::Meta::List(list) => {
                     let args = list.parse_args_with(
                         Punctuated::::parse_separated_nonempty,
                     )?;
 
-                    let mut deprecation =
-                        quote! { crate::foundations::Deprecation::new() };
-
                     if let Some(message) = args.iter().find_map(|pair| {
                         pair.path.is_ident("message").then_some(&pair.value)
                     }) {
@@ -80,10 +78,20 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result {
                     }) {
                         deprecation = quote! { #deprecation.with_until(#version) }
                     }
+                }
+                syn::Meta::Path(_) => bail!(attr, "expected deprecation message"),
+            }
 
-                    def = quote! { #def.deprecated(#deprecation) }
+            def = quote! { #def.with_deprecation(#deprecation) }
+        }
+
+        if let Some(attr) = attrs.iter().find(|attr| attr.path().is_ident("feature")) {
+            match &attr.meta {
+                syn::Meta::NameValue(pair) => {
+                    let feature = &pair.value;
+                    def = quote! { #def.with_feature(::typst_library::Feature::#feature) }
                 }
-                _ => {}
+                _ => bail!(attr, "expected feature name"),
             }
         }
 
diff --git a/crates/typst-utils/src/bitset.rs b/crates/typst-utils/src/bitset.rs
index fa57e63138a2..33c89003516e 100644
--- a/crates/typst-utils/src/bitset.rs
+++ b/crates/typst-utils/src/bitset.rs
@@ -13,7 +13,7 @@ const BITS: usize = usize::BITS as usize;
 /// Unless you're managing small numbers yourself, you should likely prefer
 /// `SmallBitSet`, which has a bit larger memory size, but does not allocate
 /// for small numbers.
-#[derive(Clone, PartialEq, Hash)]
+#[derive(Clone, Eq, PartialEq, Hash)]
 pub struct BitSet(ThinVec);
 
 impl BitSet {
@@ -63,7 +63,7 @@ impl Debug for BitSet {
 /// Efficiently stores a set of numbers which are expected to be very small.
 /// Values `< 32/64` (depending on the architecture) are stored inline, while
 /// values larger than that will lead to an allocation.
-#[derive(Clone, PartialEq, Hash)]
+#[derive(Clone, Eq, PartialEq, Hash)]
 pub struct SmallBitSet {
     /// Used to store values < BITS.
     low: usize,
diff --git a/docs/assets/docs.css b/docs/assets/docs.css
index d6845f11a087..1059482ad12b 100644
--- a/docs/assets/docs.css
+++ b/docs/assets/docs.css
@@ -58,6 +58,8 @@ body.docs > .main-grid {
 h1 {
   display: flex;
   align-items: baseline;
+  /* Handle long deprecation messages */
+  flex-wrap: wrap;
   gap: 16px;
 }
 
@@ -1472,11 +1474,11 @@ h4 + h5.scoped-definition {
   text-decoration: line-through;
 }
 
-.deprecation {
+.definition-info {
   align-items: baseline;
 }
 
-.deprecation svg {
+.definition-info svg {
   margin-bottom: -4px;
   padding: 0px;
 }
diff --git a/docs/assets/index.typ b/docs/assets/index.typ
index 1703bb607f4b..79d0964d879b 100644
--- a/docs/assets/index.typ
+++ b/docs/assets/index.typ
@@ -27,6 +27,7 @@
   "16-hamburger-dark.svg",
   "16-link.svg",
   "16-search-gray.svg",
+  "16-toggle.svg",
   "16-warn.svg",
   "32-reference-c.svg",
   "32-tutorial-c.svg",
diff --git a/docs/components/base.typ b/docs/components/base.typ
index 8e89afee9062..b87e1045ef03 100644
--- a/docs/components/base.typ
+++ b/docs/components/base.typ
@@ -142,6 +142,7 @@
 
 // Similar to `icon` but ensures that the SVG will respect the current
 // foreground `color` by emitting an inline `` with a `` element.
+// NOTE: The icon needs to be added `docs/assets/index.typ`.
 #let use-icon(size, name, alt) = html.elem(
   "svg",
   attrs: {
@@ -221,23 +222,37 @@
     .join()
 }
 
-// Displays a deprecation info, if any.
-#let deprecation(info) = {
+// Displays binding information, if any, such as:
+// - A deprecation
+// - A feature gate
+#let definition-info(info) = {
   if info == none { return }
 
-  let body = {
-    text-with-code(info.message)
-    if info.until != none {
-      [; it will be removed in Typst #info.until]
+  let item(size, name, alt, body) = {
+    context if target() == "paged" {
+      small(icon(size, name, alt) + [ ] + body)
+    } else {
+      html.small(class: "definition-info", {
+        html.div(use-icon(size, name, alt))
+        html.span(body)
+      })
     }
   }
 
-  context if target() == "paged" {
-    small(icon(16, "warn", "Warning") + [ ] + body)
-  } else {
-    html.small(class: "deprecation", {
-      html.div(use-icon(16, "warn", "Warning"))
-      html.span(body)
+  if info.feature != none {
+    item(16, "toggle", "Feature toggle", {
+      [Requires the ]
+      raw(info.feature)
+      [ feature]
+    })
+  }
+
+  if info.deprecation != none {
+    item(16, "warn", "Warning", {
+      text-with-code(info.deprecation.message)
+      if info.deprecation.until != none {
+        [; it will be removed in Typst #info.deprecation.until]
+      }
     })
   }
 }
diff --git a/docs/components/category.typ b/docs/components/category.typ
index 98e53c526ab2..a090c0eb7b27 100644
--- a/docs/components/category.typ
+++ b/docs/components/category.typ
@@ -1,12 +1,12 @@
 #import "system.typ": colors
 #import "base.typ": (
-  classnames, deprecation, folding-details, heading-offset, html-heading-n,
-  labelled, oneliner, paged-heading-offset, short-or-long, small, use-icon,
-  title-case, to-func, with-tooltip,
+  classnames, definition-info, folding-details, heading-offset, html-heading-n,
+  labelled, oneliner, paged-heading-offset, short-or-long, small, title-case,
+  to-func, use-icon, with-tooltip,
 )
 #import "example.typ": example, example-like-block
 #import "linking.typ": def-dest, def-label, register-def
-#import "live.typ": live-docs, item-source-link
+#import "live.typ": item-source-link, live-docs
 #import "pill.typ": ty-pill
 #import "reflect.typ": cast-strings, flat-types, std-path-of
 #import "search.typ": register-index-item
@@ -14,6 +14,32 @@
 #import "styling.typ": prose-styling
 #import "table.typ": docs-table
 
+
+// Build a scope from a module and binding info.
+#let scope-from(mod, info) = (
+  mod: mod,
+  dict: dictionary(mod),
+  info: info,
+)
+
+// Build the scope information for a module.
+//
+// Requires the parent module and the name of the module.
+#let scope(parent, key) = scope-from(
+  dictionary(parent).at(key),
+  stdx.binding(parent, key),
+)
+
+// Get nested binding information, feature gates of the parent scope info will
+// be forwarded to the nested item info.
+#let nested-binding(scope, key) = {
+  let info = stdx.binding(scope.mod, key)
+  if info.feature == none {
+    info.feature = scope.info.feature
+  }
+  info
+}
+
 // Displays the overview box for a function signature.
 #let param-signature(func, path, params, returns) = context {
   let func-label = def-label(func)
@@ -265,7 +291,7 @@
 //
 // When the labels are changed here, `docs/content/reference/index.typ` needs to
 // change, too.
-#let func-subtitle(info, deprecation-info) = context {
+#let func-subtitle(info, binding-info) = context {
   let gap = if target() == "paged" { h(0.5em, weak: true) }
   if info.element {
     set text(0.75em)
@@ -289,12 +315,12 @@
     panic("missing `since` for function " + info.def-site.key + " (" + stdx.str-from-path(info.def-site.path) + ")")
   }
   gap
-  deprecation(deprecation-info)
+  definition-info(binding-info)
   sources-link(info)
 }
 
 // Displays additional details about a type.
-#let ty-subtitle(ty-info, deprecation-info) = context {
+#let ty-subtitle(ty-info, binding-info) = context {
   let gap = if target() == "paged" { h(0.5em, weak: true) }
   if ty-info.since != none {
     set text(0.75em)
@@ -303,7 +329,7 @@
   } else {
     panic("missing `since` for type " + ty-info.def-site.key + " (" + stdx.str-from-path(ty-info.def-site.path) + ")")
   }
-  deprecation(deprecation-info)
+  definition-info(binding-info)
   sources-link(ty-info)
 }
 
@@ -312,9 +338,11 @@
 #let func-member(
   func,
   base-label: none,
-  deprecation-info: none,
+  binding-info: none,
   definitions-section: none,
 ) = {
+  assert.ne(binding-info, none, message: "binding-info is required")
+
   let info = stdx.describe(to-func(func))
   let base-label = label(str(base-label) + "-" + info.name)
   let muted = "typed-html" in info.keywords
@@ -337,7 +365,7 @@
           it.level + 1,
           class: classnames(
             "scoped-definition",
-            deprecated: deprecation-info != none,
+            deprecated: binding-info.deprecation != none,
           ),
           it.body,
         )
@@ -345,7 +373,7 @@
     }
     let title = short-or-long(
       info.title,
-      raw(info.name) + func-subtitle(info, deprecation-info),
+      raw(info.name) + func-subtitle(info, binding-info),
     )
     labelled(heading(depth: 2, title), base-label)
   }
@@ -377,9 +405,10 @@
     muted: muted,
   ))
 
+
   heading-offset(2, definitions-section(
     info.name,
-    info.scope,
+    scope-from(info.scope, binding-info),
     base-label: label(str(base-label) + "-definitions"),
   ))
 }
@@ -425,9 +454,11 @@
 #let ty-member(
   ty,
   base-label: none,
-  deprecation-info: none,
+  binding-info: none,
   definitions-section: none,
 ) = {
+  assert.ne(binding-info, none, message: "binding-info is required")
+
   let info = stdx.describe(ty)
   let base-label = label(str(base-label) + "-" + info.short-name)
 
@@ -447,7 +478,7 @@
           it.level + 1,
           class: classnames(
             "scoped-definition",
-            deprecated: deprecation-info != none,
+            deprecated: binding-info.deprecation != none,
           ),
           it.body
         )
@@ -455,7 +486,7 @@
     }
     let title = short-or-long(
       info.title,
-      text(size: 13pt, ty-pill(ty, linked: false)) + ty-subtitle(info, deprecation-info),
+      text(size: 13pt, ty-pill(ty, linked: false)) + ty-subtitle(info, binding-info),
     )
     labelled(heading(depth: 2, title), base-label)
   }
@@ -474,15 +505,14 @@
 
   heading-offset(2, definitions-section(
     info.short-name,
-    info.scope,
+    scope-from(info.scope, binding-info),
     base-label: label(str(base-label) + "-definitions"),
   ))
 }
 
 // Renders a section that documents definitions on a type or function.
-#let definitions-section(parent, mod, base-label: ) = {
-  let scope = dictionary(mod)
-  if scope.len() == 0 {
+#let definitions-section(parent, scope, base-label: ) = {
+  if scope.dict.len() == 0 {
     return
   }
 
@@ -498,19 +528,19 @@
 
   labelled(heading(title), base-label)
 
-  for (name, value) in scope {
+  for (name, value) in scope.dict {
     if type(value) == function {
       func-member(
         value,
         base-label: base-label,
-        deprecation-info: stdx.binding(mod, name).deprecation,
+        binding-info: nested-binding(scope, name),
         definitions-section: definitions-section,
       )
     } else if type(value) == type {
       ty-member(
         value,
         base-label: base-label,
-        deprecation-info: stdx.binding(mod, name).deprecation,
+        binding-info: nested-binding(scope, name),
         definitions-section: definitions-section,
       )
     }
@@ -531,13 +561,13 @@
 }
 
 // Renders a section for a function.
-#let func-section(base-route, name, func, info, deprecation-info) = {
+#let func-section(base-route, name, func, info, binding-info) = {
   show: func-or-ty-section.with(
     kind: "Function",
     route: base-route + "/" + name,
     title: info.title,
     title-fmt: raw(info.name),
-    subtitle: func-subtitle(info, deprecation-info),
+    subtitle: func-subtitle(info, binding-info),
     has-summary: true,
     keywords: info.keywords,
     def-target: func,
@@ -561,16 +591,19 @@
     )
   }
 
-  definitions-section(info.name, info.scope)
+  definitions-section(
+    info.name,
+    scope-from(info.scope, binding-info),
+  )
 }
 
 // Renders a section for a type.
-#let ty-section(base-route, name, ty, ty-info, deprecation-info) = {
+#let ty-section(base-route, name, ty, ty-info, binding-info) = {
   show: func-or-ty-section.with(
     route: base-route + "/" + name,
     title: ty-info.title,
     title-fmt: ty-pill(ty, linked: false),
-    subtitle: ty-subtitle(ty-info, deprecation-info),
+    subtitle: ty-subtitle(ty-info, binding-info),
     has-summary: true,
     kind: "Type",
     keywords: ty-info.keywords,
@@ -587,7 +620,10 @@
     constructor-section(ty-info.constructor)
   }
 
-  definitions-section(ty-info.short-name, ty-info.scope)
+  definitions-section(
+    ty-info.short-name,
+    scope-from(ty-info.scope, binding-info),
+  )
 }
 
 // The definition target for a group.
@@ -617,8 +653,12 @@
   if info.items.len() > 0 {
     let base-label = 
     labelled(heading[Functions], base-label)
-    for item in info.items {
-      func-member(item, base-label: base-label)
+    for (key, value) in info.items {
+      func-member(
+        value,
+        base-label: base-label,
+        binding-info: nested-binding(info.scope, key),
+      )
     }
   }
 
@@ -719,7 +759,7 @@
   description: none,
   // The scope from which the definitions to be documented are taken. It is
   // filtered by category.
-  scope: std,
+  scope: none,
   // Additional definitions to document.
   scope-additions: (:),
   // Definitions that should be documented together as a group.
@@ -730,32 +770,39 @@
   assert.ne(category, none, message: "category is required")
 
   let route = "/reference/" + category
-  let def-target = if scope == std {
+  let def-target = if scope == none {
     label("reference:" + category)
   } else {
+    scope.mod
+  }
+
+  let scope = if scope != none {
     scope
+  } else {
+    scope-from(std, (feature: none))
   }
 
   let definitions = {
     // Non-grouped definitions from the scope.
-    let skip = groups.map(g => g.items).flatten()
-    dictionary(scope)
+    let skip = groups.map(g => g.items.map(((k, v)) => v)).flatten()
+    scope
+      .dict
       .pairs()
       .filter(((k, v)) => (
-        stdx.binding(scope, k).category == category
+        stdx.binding(scope.mod, k).category == category
           and type(v) in (function, type)
           and v not in skip
           and not (
-            (scope == math and k == "text") // dupe
-              or (scope == pdf and k == "embed") // deprecated
-              or (scope == std and k == "pattern") // deprecated
+            (scope.mod == math and k == "text") // dupe
+              or (scope.mod == pdf and k == "embed") // deprecated
+              or (scope.mod == std and k == "pattern") // deprecated
           )
       ))
       .map(((k, v)) => (
         k,
         v,
         stdx.describe(v),
-        stdx.binding(scope, k).deprecation,
+        nested-binding(scope, k),
       ))
 
     // Manual ddditions.
@@ -785,12 +832,15 @@
 
   // The individual sections for all definitions.
   show: paged-heading-offset.with(1)
-  for (name, value, info, deprecation-info) in definitions {
+  for (name, value, info, binding-info) in definitions {
     if type(value) == function {
-      func-section(route, name, value, info, deprecation-info)
+      func-section(route, name, value, info, binding-info)
     } else if type(value) == type {
-      ty-section(route, name, value, info, deprecation-info)
+      ty-section(route, name, value, info, binding-info)
     } else {
+      if info.at("scope", default: none) == none {
+        info.scope = scope
+      }
       group-section(route, def-target, info)
     }
   }
diff --git a/docs/components/index.typ b/docs/components/index.typ
index 2f50a6056740..76695073cc97 100644
--- a/docs/components/index.typ
+++ b/docs/components/index.typ
@@ -1,6 +1,6 @@
 #import "system.typ": *
 #import "base.typ": *
-#import "category.typ": docs-category, modifier-list
+#import "category.typ": docs-category, modifier-list, scope
 #import "example.typ": example
 #import "figure.typ": docs-figure, side-by-side
 #import "linking.typ": def-dest
diff --git a/docs/content/reference/export/html.typ b/docs/content/reference/export/html.typ
index 9a4e139ec5b4..76749e680091 100644
--- a/docs/content/reference/export/html.typ
+++ b/docs/content/reference/export/html.typ
@@ -1,18 +1,18 @@
-#import "../../../components/index.typ": docs-category, info, insertion
+#import "../../../components/index.typ": docs-category, info, insertion, scope
 
 #show: docs-category.with(
   title: "HTML",
   description: "Documentation for Typst's HTML export target.",
   category: "html",
-  scope: html,
+  scope: scope(std, "html"),
   groups: (
     (
       name: "typed",
       title: "Typed HTML",
       items: {
         dictionary(html)
-          .values()
-          .filter(val => {
+          .pairs()
+          .filter(((_, val)) => {
             let info = stdx.describe(val)
             info != none and "typed-html" in info.keywords
           })
diff --git a/docs/content/reference/export/pdf.typ b/docs/content/reference/export/pdf.typ
index 963aac7282b9..4f3e352ebc58 100644
--- a/docs/content/reference/export/pdf.typ
+++ b/docs/content/reference/export/pdf.typ
@@ -1,10 +1,10 @@
-#import "../../../components/index.typ": docs-category
+#import "../../../components/index.typ": docs-category, scope
 
 #show: docs-category.with(
   title: "PDF",
   description: "Documentation for Typst's PDF export target.",
   category: "pdf",
-  scope: pdf,
+  scope: scope(std, "pdf"),
 )
 
 PDF files focus on accurately describing documents visually, but also have facilities for annotating their structure. This hybrid approach makes them a good fit for document exchange: They render exactly the same on every device, but also support extraction of a document's content and structure (at least to an extent). Unlike PNG files, PDFs are not bound to a specific resolution. Hence, you can view them at any size without incurring a loss of quality.
diff --git a/docs/content/reference/library/foundations.typ b/docs/content/reference/library/foundations.typ
index 751ae7df9b6f..01a3aaaa35cc 100644
--- a/docs/content/reference/library/foundations.typ
+++ b/docs/content/reference/library/foundations.typ
@@ -1,4 +1,4 @@
-#import "../../../components/index.typ": docs-category
+#import "../../../components/index.typ": docs-category, scope
 
 #show: docs-category.with(
   title: "Foundations",
@@ -13,7 +13,8 @@
       name: "calc",
       def-target: calc,
       title: "Calculation",
-      items: dictionary(calc).values().filter(v => type(v) == function),
+      scope: scope(std, "calc"),
+      items: dictionary(calc).pairs().filter(((k, v)) => type(v) == function),
       description: "Documentation for the `calc` module, which contains definitions for mathematical computation.",
       docs: [
         Module for calculations and processing of numeric values.
diff --git a/docs/content/reference/library/math.typ b/docs/content/reference/library/math.typ
index 1cca11415a67..357dfe61e078 100644
--- a/docs/content/reference/library/math.typ
+++ b/docs/content/reference/library/math.typ
@@ -1,23 +1,20 @@
-#import "../../../components/index.typ": docs-category
+#import "../../../components/index.typ": docs-category, scope
+
+#let math-definitions = dictionary(math)
+#let math-items(..keys) = {
+  keys.pos().map(k => (k, math-definitions.at(k)))
+}
 
 #show: docs-category.with(
   title: "Math",
   description: "Documentation for math mode and the `math` module, which together enable high-quality math typesetting.",
   category: "math",
-  scope: math,
+  scope: scope(std, "math"),
   groups: (
     (
       name: "variants",
       title: "Variants",
-      items: (
-        math.serif,
-        math.sans,
-        math.frak,
-        math.mono,
-        math.bb,
-        math.cal,
-        math.scr,
-      ),
+      items: math-items("serif", "sans", "frak", "mono", "bb", "cal", "scr"),
       description: "Documentation for functions which allow switching to alternative math typefaces.",
       docs: [
         Alternate typefaces within formulas.
@@ -28,7 +25,7 @@
     (
       name: "styles",
       title: "Styles",
-      items: (math.upright, math.italic, math.bold),
+      items: math-items("upright", "italic", "bold"),
       description: "Documentation for functions which allow switching to alternative math letterforms.",
       docs: [
         Alternate letterforms within formulas.
@@ -39,7 +36,7 @@
     (
       name: "sizes",
       title: "Sizes",
-      items: (math.display, math.inline, math.script, math.sscript),
+      items: math-items("display", "inline", "script", "sscript"),
       description: "Documentation for functions which allow switching to alternative math text sizes.",
       docs: [
         Forced size styles for expressions within formulas.
@@ -50,17 +47,17 @@
     (
       name: "underover",
       title: "Under/Over",
-      items: (
-        math.underline,
-        math.overline,
-        math.underbrace,
-        math.overbrace,
-        math.underbracket,
-        math.overbracket,
-        math.underparen,
-        math.overparen,
-        math.undershell,
-        math.overshell,
+      items: math-items(
+        "underline",
+        "overline",
+        "underbrace",
+        "overbrace",
+        "underbracket",
+        "overbracket",
+        "underparen",
+        "overparen",
+        "undershell",
+        "overshell",
       ),
       description: "Documentation for functions that add delimiters above or below parts of an equation.",
       docs: [
@@ -72,7 +69,7 @@
     (
       name: "roots",
       title: "Roots",
-      items: (math.root, math.sqrt),
+      items: math-items("root", "sqrt"),
       description: "Documentation for functions that typeset mathematical roots.",
       docs: [
         Square and non-square roots.
@@ -87,7 +84,7 @@
     (
       name: "attach",
       title: "Attach",
-      items: (math.attach, math.scripts, math.limits),
+      items: math-items("attach", "scripts", "limits"),
       description: "Documentation for functions that allows to precisely attach sub-, superscripts, and limits to parts of an equation.",
       docs: [
         Subscript, superscripts, and limits.
@@ -108,15 +105,7 @@
     (
       name: "lr",
       title: "Left/Right",
-      items: (
-        math.lr,
-        math.mid,
-        math.abs,
-        math.norm,
-        math.floor,
-        math.ceil,
-        math.round,
-      ),
+      items: math-items("lr", "mid", "abs", "norm", "floor", "ceil", "round"),
       description: "Documentation for functions that enable typesetting of matched, potentially scaled, delimiters.",
       docs: [
         Delimiter matching.
diff --git a/docs/src/reflect.rs b/docs/src/reflect.rs
index 60774e988092..ae2cf630df58 100644
--- a/docs/src/reflect.rs
+++ b/docs/src/reflect.rs
@@ -28,6 +28,7 @@ pub fn binding(module: Module, name: EcoString) -> Option {
     let binding = module.scope().get(&name)?;
     Some(dict! {
         "category" => binding.category().map(|c| c.name()),
+        "feature" => binding.feature().map(|f| f.to_string()),
         "deprecation" => binding.deprecation().map(|d| dict! {
             "message" => d.message(),
             "until" => d.until(),
diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt
index 981f4182a50c..9019986bf1cf 100644
--- a/tests/ref/pdf/hashes.txt
+++ b/tests/ref/pdf/hashes.txt
@@ -1824,7 +1824,7 @@ fc1f3964c3f9e1f668dea0e3c4d264cc sub-super-typographic
 5804612e6780b9568b9c75a2b2292b72 symbol
 03215022d93e92caa99976bb13e526f8 symbol-constructor
 c3dcac7395916a5744d93a97fff86f4c symbol-modifier-deprecated
-48ec0b459b27c4c90cb8c75ee0aabe99 symbol-sect-deprecated
+871ddbab107fc0b938fa02498dc39d5c symbol-sect-deprecated
 17ef2fb2d79d3aef3cbd09ceb1c66090 table-align-array
 5996856077120cf278e8c663a6c7048e table-cell-align-override
 29b29e77ad9aa1ab6fb43cb868ab932e table-cell-folding
diff --git a/tests/ref/render/symbol-sect-deprecated.png b/tests/ref/render/symbol-sect-deprecated.png
index 2b366987268e55307a6d3f918848c1cf5a7283c8..21b5a011b51213b65b6ccc1a579ac77bf4c759d2 100644
GIT binary patch
delta 542
zcmV+(0^$9)1F8g&7k@Vh00000;sMdO0005;NklEHzBx%
z_1+7{Ub!Hb>r^=57O0=%$FkC`@&Qki5?~ukZ83H{up{Fb%)G1uaODm+jaWT)Oc!#X
zr4X!dYaLVr7TA%_@WU)O`@Gfb`5ZqrHF$4zrGi1zlv3aWF3#gn&{SiE+l^u{QQiwy
z$|a^5EqNq<3ZGbZUi=o^+qV4xz<0)+UDj`7^SpX30DrLSwdCIExA8nTe-i-MFfv5@
zZ;XyjACX@l0uz|Pe+TAXzT%^#ur2ylOdASm;ZWHj3S-eR(!z75)0KL7v#07*qoM6N<$g7pvi<^TWy

delta 426
zcmV;b0agC01h)f_7k?!P00000tRHeG0004gNklphPLhMZ}#jfCbpD)Db>~)pYw7BMf>2e^oJLzvF4vUw*
z^nU`RbJqQ*vc><)R)FjU8~bzR!5w?iE&l&9;OYO|b^pOCu75ATyz(BEEdF0!nfw=G
z?212bH`;*oi*7J@0kQc1%fQt2|G_GLt$Jj4ayPXs{-pfmKg8lc|D7;{8c3}1{SQ&`
z@86bQss;6@-~)coAr{{_pY{JFrjHLMEh~gr{QhO(i5nlOWAUe;oByBrJqH>4J9bXh
z+&36LKA8CD|9`4Nu*EwX>Zh-uj*mYD-T+e1e4pV=)d%AL0O^$l)J@g70XM+(Q=bGJ
z7N^Gj0W((SQ`O?8>rlqiLhK6O6#j;?)~*_@Bq+7`){C=0@GAIw`u#;3Sp52W%(<6%
zRUChizn=yc|NfQ!?k8Rq-#2Z3P6LZi&YgV_zlwQ_)+1aSZTgH_JZkZ%#lzfU02J5t
UsKaVT1ONa407*qoM6N<$f(`KA82|tP

diff --git a/tests/ref/svg/hashes.txt b/tests/ref/svg/hashes.txt
index e1838e9877a4..9fba0712037e 100644
--- a/tests/ref/svg/hashes.txt
+++ b/tests/ref/svg/hashes.txt
@@ -1818,7 +1818,7 @@ b64d264f46907d6864cec01a3fa30aae super-underline
 9eb85970c607bd685a084feef5a2f2e2 symbol
 39d7f353b3cf598d45674cf7cb21fc20 symbol-constructor
 a6803b73c3c1149b4e2972c54f79635a symbol-modifier-deprecated
-9c17d83a2556c6b5755e15ed4d9fce7f symbol-sect-deprecated
+61809cb9c24d6f8431be6a4dc9bf7fb8 symbol-sect-deprecated
 adfb436c3a5c05d3b1f5f5d4fc9810f0 table-align-array
 0f85b8f9611c29330864ebde678621d8 table-cell-align-override
 a6a47fe5db49fd35fa379b2d06f51356 table-cell-folding
diff --git a/tests/src/collect.rs b/tests/src/collect.rs
index 1903ef1e7165..3c95e313dcde 100644
--- a/tests/src/collect.rs
+++ b/tests/src/collect.rs
@@ -8,6 +8,7 @@ use bitflags::{Flags, bitflags};
 use ecow::EcoString;
 use rustc_hash::{FxHashMap, FxHashSet};
 use typst::foundations::Target;
+use typst::{Feature, Features};
 use typst_pdf::PdfStandard;
 use typst_syntax::{is_id_continue, is_ident, is_newline};
 use unscanny::Scanner;
@@ -91,10 +92,11 @@ bitflags! {
 }
 
 /// The parsed and evaluated test attributes specified in the test header.
-#[derive(Debug, Default, Clone, Eq, PartialEq)]
+#[derive(Debug, Default, Clone, PartialEq)]
 pub struct Attrs {
     pub large: bool,
     pub empty: bool,
+    pub features: Option,
     pub pdf_standard: Vec,
     /// Tolerance for image comparisons. Render tests are not 100% reproducible.
     /// By default, we allow a byte difference of 1, but in rare cases, we need
@@ -687,6 +689,7 @@ impl<'a> Parser<'a> {
     fn parse_attrs(&mut self) -> Attrs {
         let mut stages = TestStages::empty();
         let mut flags = AttrFlags::empty();
+        let mut features = None;
         let mut pdf_standard = Vec::new();
         let mut tolerance = None;
 
@@ -709,23 +712,14 @@ impl<'a> Parser<'a> {
                 "pdf" => self.set_attr(attr_name, &mut stages, TestStages::PDF),
                 "pdftags" => self.set_attr(attr_name, &mut stages, TestStages::PDFTAGS),
                 "pdfstandard" => {
-                    let Some(param) = attr_params.take() else {
+                    let Some(params) = attr_params.take() else {
                         self.error("expected parameter for `pdfstandard`");
                         continue;
                     };
-                    pdf_standard = param
-                        .split(',')
-                        .map(str::trim)
-                        .filter_map(|s| {
+                    pdf_standard =
+                        self.parse_attr_param_list(params, "pdf standard", |s| {
                             serde_yaml::from_str(s)
-                                .inspect_err(|e| {
-                                    self.error(format!(
-                                        "unknown pdf standard `{s}`: {e}"
-                                    ));
-                                })
-                                .ok()
-                        })
-                        .collect();
+                        });
                 }
                 "tolerance" => {
                     let Some(param) = attr_params.take() else {
@@ -741,6 +735,16 @@ impl<'a> Parser<'a> {
                 "bundle" => self.set_attr(attr_name, &mut stages, TestStages::BUNDLE),
                 "large" => self.set_attr(attr_name, &mut flags, AttrFlags::LARGE),
                 "empty" => self.set_attr(attr_name, &mut flags, AttrFlags::EMPTY),
+                "features" => {
+                    let Some(params) = attr_params.take() else {
+                        self.error("expected parameter for `features`");
+                        continue;
+                    };
+                    // Allow specifying an empty parameter list.
+                    features = Some(self.parse_attr_param_list(params, "feature", |s| {
+                        Feature::from_str(s).map_err(|()| "")
+                    }));
+                }
 
                 found => {
                     self.error(format!(
@@ -775,12 +779,41 @@ impl<'a> Parser<'a> {
         Attrs {
             large: flags.contains(AttrFlags::LARGE),
             empty: flags.contains(AttrFlags::EMPTY),
+            features,
             pdf_standard,
             stages,
             tolerance,
         }
     }
 
+    /// Parse a comma-separated attribute parameter list.
+    fn parse_attr_param_list(
+        &mut self,
+        params: &str,
+        what: &str,
+        parse: F,
+    ) -> T
+    where
+        T: FromIterator + Default,
+        F: Fn(&str) -> Result,
+        E: Display,
+    {
+        // Allow specifying an empty parameter list.
+        if params.trim().is_empty() {
+            return T::default();
+        }
+
+        params
+            .split(',')
+            .map(str::trim)
+            .filter_map(|p| {
+                parse(p)
+                    .inspect_err(|e| self.error(format!("unknown {what} `{p}`: {e}")))
+                    .ok()
+            })
+            .collect()
+    }
+
     /// Set an attribute flag and check for duplicates.
     fn set_attr(&mut self, attr: &str, flags: &mut F, flag: F) {
         if flags.contains(flag) {
diff --git a/tests/src/run.rs b/tests/src/run.rs
index 854164099af5..f04952cc7758 100644
--- a/tests/src/run.rs
+++ b/tests/src/run.rs
@@ -122,7 +122,7 @@ impl UnexpectedEmpty {
 impl<'a> Runner<'a> {
     /// Create a new test runner.
     fn new(hashes: &'a [RwLock], test: &'a mut Test) -> Self {
-        let world = TestWorld::new(test.body.source.clone());
+        let world = TestWorld::new(test.body.source.clone(), test.attrs.features.clone());
         Self {
             hashes,
             test,
diff --git a/tests/src/world.rs b/tests/src/world.rs
index 84bc970988ae..17e889a1d5f5 100644
--- a/tests/src/world.rs
+++ b/tests/src/world.rs
@@ -2,13 +2,17 @@ use std::fs;
 use std::io::Write;
 use std::path::PathBuf;
 use std::str::FromStr;
+use std::sync::Arc;
 
 use comemo::Tracked;
+use parking_lot::Mutex;
+use rustc_hash::FxHashMap;
 use typst::diag::{At, FileError, FileResult, SourceResult, StrResult, bail};
 use typst::engine::Engine;
 use typst::foundations::{
-    Array, Bytes, Content, Context, Datetime, Duration, IntoValue, NativeElement,
-    NoneValue, Packed, Repr, Smart, StyleChain, Value, elem, func,
+    Array, Bytes, Content, Context, Datetime, Deprecation, Duration, IntoValue, Module,
+    NativeElement, NoneValue, Packed, Repr, Scope, Smart, StyleChain, Value, elem, func,
+    scope,
 };
 use typst::introspection::Locator;
 use typst::layout::{Abs, BlockElem, Fragment, Margin, PageElem, Regions};
@@ -17,7 +21,7 @@ use typst::syntax::{FileId, Source, Span};
 use typst::text::{Font, FontBook, TextElem, TextSize};
 use typst::utils::{LazyHash, singleton};
 use typst::visualize::Color;
-use typst::{Features, Library, LibraryExt, World};
+use typst::{Feature, Features, Library, LibraryExt, World};
 use typst_kit::datetime::Time;
 use typst_kit::files::{FileLoader, FileStore};
 use typst_layout::layout_fragment;
@@ -29,6 +33,8 @@ use unscanny::Scanner;
 #[derive(Clone)]
 pub struct TestWorld {
     main: Source,
+    /// The library that should be used with this test world.
+    library: Arc>,
     base: &'static TestBase,
 }
 
@@ -37,17 +43,16 @@ impl TestWorld {
     ///
     /// This is cheap because the shared base for all test runs is lazily
     /// initialized just once.
-    pub fn new(source: Source) -> Self {
-        Self {
-            main: source,
-            base: singleton!(TestBase, TestBase::default()),
-        }
+    pub fn new(source: Source, features: Option) -> Self {
+        let base = singleton!(TestBase, TestBase::default());
+        let library = base.lib.with_features(features);
+        Self { main: source, library, base }
     }
 }
 
 impl World for TestWorld {
     fn library(&self) -> &LazyHash {
-        &self.base.library
+        &self.library
     }
 
     fn book(&self) -> &LazyHash {
@@ -86,7 +91,7 @@ impl World for TestWorld {
 
 /// Shared foundation of all test worlds.
 struct TestBase {
-    library: LazyHash,
+    lib: Libraries,
     book: LazyHash,
     fonts: Vec,
     files: FileStore,
@@ -100,7 +105,7 @@ impl Default for TestBase {
             .collect();
 
         Self {
-            library: LazyHash::new(library()),
+            lib: Libraries::new(library()),
             book: LazyHash::new(FontBook::from_fonts(&fonts)),
             fonts,
             files: FileStore::new(TestFiles),
@@ -108,6 +113,41 @@ impl Default for TestBase {
     }
 }
 
+/// The base library, and a set of interned libraries with overrides.
+struct Libraries {
+    /// The default base library.
+    base: Arc>,
+    /// Interned libraries that only have a subset of features enabled.
+    overrides: Mutex>>>,
+}
+
+impl Libraries {
+    fn new(base: Library) -> Self {
+        Self {
+            base: Arc::new(LazyHash::new(base)),
+            overrides: Mutex::new(FxHashMap::default()),
+        }
+    }
+}
+
+impl Libraries {
+    /// Retrieve a library with the specific set of features enabled.
+    /// If `features` is `None` the default base library is returned.
+    fn with_features(&self, features: Option) -> Arc> {
+        let Some(features) = features else {
+            return Arc::clone(&self.base);
+        };
+
+        let mut overrides = self.overrides.lock();
+        let lib = overrides.entry(features.clone()).or_insert_with(|| {
+            let mut lib = Library::clone(&*self.base);
+            lib.features = features;
+            Arc::new(LazyHash::new(lib))
+        });
+        Arc::clone(lib)
+    }
+}
+
 /// Loads files from the test suite, Typst assets, and the test packages.
 /// Excludes the main source file, which is directly handled by the `World`.
 pub struct TestFiles;
@@ -174,6 +214,7 @@ fn library() -> Library {
     let mut lib = Library::builder().with_features(Features::all()).build();
 
     // Hook up helpers into the global scope.
+    lib.global.scope_mut().define("check", check_module());
     lib.global.scope_mut().define_func::();
     lib.global.scope_mut().define_func::();
     lib.global.scope_mut().define_func::();
@@ -198,7 +239,17 @@ fn library() -> Library {
     lib
 }
 
-#[func]
+fn check_module() -> Module {
+    let mut check = Scope::new();
+    check
+        .define("deprecated", Value::None)
+        .with_deprecation(Deprecation::new().with_message("this value is useless"));
+    check.define("gated", Value::None).with_feature(Feature::Html);
+    check.define("red", Color::RED);
+    Module::new("check", check)
+}
+
+#[func(scope)]
 fn test(lhs: Value, rhs: Value) -> StrResult {
     if lhs != rhs {
         bail!("Assertion failed: {} != {}", lhs.repr(), rhs.repr());
@@ -206,6 +257,21 @@ fn test(lhs: Value, rhs: Value) -> StrResult {
     Ok(NoneValue)
 }
 
+#[scope]
+impl test {
+    #[func]
+    #[deprecated(message = "this function is useless")]
+    fn deprecated() -> StrResult {
+        Ok(Value::None)
+    }
+
+    #[func]
+    #[feature = Html]
+    fn gated() -> StrResult {
+        Ok(Value::None)
+    }
+}
+
 #[func]
 fn test_repr(lhs: Value, rhs: Value) -> StrResult {
     if lhs.repr() != rhs.repr() {
diff --git a/tests/suite/foundations/dict.typ b/tests/suite/foundations/dict.typ
index 75c07e5c670d..161abaa311a0 100644
--- a/tests/suite/foundations/dict.typ
+++ b/tests/suite/foundations/dict.typ
@@ -191,6 +191,13 @@
 #test(type(dictionary(sys).at("version")), version)
 #test(dictionary(sys).at("no-crash", default: none), none)
 
+--- dict-from-module-feature-gated-item eval features() ---
+// Error: 20-25 dictionary does not contain key "gated"
+#dictionary(check).gated
+
+--- dict-from-module-deprecation-ignored eval ---
+#dictionary(check).deprecated
+
 --- dict-remove-order eval ---
 // Test that removal keeps order.
 #let dict = (a: 1, b: 2, c: 3, d: 4)
diff --git a/tests/suite/foundations/std.typ b/tests/suite/foundations/std.typ
index 9eff1b74acaa..a6c4f0b88dc7 100644
--- a/tests/suite/foundations/std.typ
+++ b/tests/suite/foundations/std.typ
@@ -29,3 +29,9 @@
 
 --- std-math paged ---
 $ std.rect(x + y = 5) $
+
+--- std-gated-contains-enabled eval features(html) ---
+#test("html" in std, true)
+
+--- std-gated-contains-disabled eval features() ---
+#test("html" in std, false)
diff --git a/tests/suite/math/attach.typ b/tests/suite/math/attach.typ
index 96a8bea2cfad..90759ca67602 100644
--- a/tests/suite/math/attach.typ
+++ b/tests/suite/math/attach.typ
@@ -99,7 +99,7 @@ $ scripts(sum)_1^2 != sum_1^2 $
 $ limits(integral)_a^b != integral_a^b $
 
 --- issue-math-attach-realize-panic paged html ---
-// Error: 25-29 unknown variable: oops
+// Error: 25-29 unknown variable `oops`
 $ attach(A, t: #context oops) $
 
 --- math-attach-show-limit paged html ---
diff --git a/tests/suite/math/call.typ b/tests/suite/math/call.typ
index d8eee341538a..78e6089e2249 100644
--- a/tests/suite/math/call.typ
+++ b/tests/suite/math/call.typ
@@ -329,7 +329,7 @@ $ vec(#1) $
 #test($sin(1)$, $sin(#1)$)
 
 --- issue-2885-math-var-only-in-global eval ---
-// Error: 7-10 unknown variable: rgb
+// Error: 7-10 unknown variable `rgb`
 // Hint: 7-10 `rgb` is not available directly in math, but is in the standard library
 // Hint: 7-10 to access `rgb` in code mode you can add a hash: `#rgb`
 // Hint: 7-10 or access `rgb` in math mode by using the `std` module: `std.rgb`
diff --git a/tests/suite/math/interactions.typ b/tests/suite/math/interactions.typ
index e2c4b1e95f45..919fcde24cc5 100644
--- a/tests/suite/math/interactions.typ
+++ b/tests/suite/math/interactions.typ
@@ -2,7 +2,7 @@
 // Hint: They are bad ...
 
 --- math-unknown-var-hint eval ---
-// Error: 4-6 unknown variable: ab
+// Error: 4-6 unknown variable `ab`
 // Hint: 4-6 if you meant to display multiple letters as is, try adding spaces between each letter: `a b`
 // Hint: 4-6 or if you meant to display this as text, try placing it in quotes: `"ab"`
 $ 5ab $
diff --git a/tests/suite/scripting/blocks.typ b/tests/suite/scripting/blocks.typ
index 4b97f37b15a4..6862541b5f3a 100644
--- a/tests/suite/scripting/blocks.typ
+++ b/tests/suite/scripting/blocks.typ
@@ -64,7 +64,7 @@
 // Block directly in markup also creates a scope.
 #{ let x = 1 }
 
-// Error: 7-8 unknown variable: x
+// Error: 7-8 unknown variable `x`
 #test(x, 1)
 
 --- code-block-scope-in-let eval ---
@@ -76,7 +76,7 @@
 
 #test(a, 1)
 
-// Error: 3-4 unknown variable: b
+// Error: 3-4 unknown variable `b`
 #{b}
 
 --- code-block-double-scope eval ---
@@ -86,7 +86,7 @@
   test(b, 1)
 }}
 
-// Error: 2-3 unknown variable: b
+// Error: 2-3 unknown variable `b`
 #b
 
 --- code-block-nested-scopes eval ---
@@ -179,5 +179,5 @@
 // Content blocks also create a scope.
 #[#let x = 1]
 
-// Error: 2-3 unknown variable: x
+// Error: 2-3 unknown variable `x`
 #x
diff --git a/tests/suite/scripting/call.typ b/tests/suite/scripting/call.typ
index 5db814c8eb3d..4b1c2cbe2d45 100644
--- a/tests/suite/scripting/call.typ
+++ b/tests/suite/scripting/call.typ
@@ -198,6 +198,12 @@
 #let f( param : v ) = param
 #test(f( param /* ok */ : 2 ), 2)
 
+--- call-feature-gated eval features() ---
+// Error: 2-6 cannot access variable `html` because the `html` feature is not enabled
+// Hint: 2-6 try enabling the `html` feature
+// Hint: 2-6 see https://typst.app/help/compiler-features for more details
+#html()
+
 --- call-args-unclosed eval ---
 // Error: 7-8 unclosed delimiter
 #{func(}
diff --git a/tests/suite/scripting/closure.typ b/tests/suite/scripting/closure.typ
index 27ee61da2854..4f67cc16db8c 100644
--- a/tests/suite/scripting/closure.typ
+++ b/tests/suite/scripting/closure.typ
@@ -104,7 +104,7 @@
 --- closure-bad-capture eval ---
 // Don't leak environment.
 #{
-  // Error: 16-17 unknown variable: x
+  // Error: 16-17 unknown variable `x`
   let func() = x
   let x = "hi"
   func()
diff --git a/tests/suite/scripting/field.typ b/tests/suite/scripting/field.typ
index 6099bfaffc7b..d5785203ee51 100644
--- a/tests/suite/scripting/field.typ
+++ b/tests/suite/scripting/field.typ
@@ -74,3 +74,15 @@
   // Hint: 3-4 try creating a new stroke with the updated field value instead
   s.thickness = 5pt
 }
+
+--- field-feature-gated eval features() ---
+// Error: 6-15 cannot access field `data-cell` because the `a11y-extras` feature is not enabled
+// Hint: 6-15 try enabling the `a11y-extras` feature
+// Hint: 6-15 see https://typst.app/help/compiler-features for more details
+#pdf.data-cell
+
+--- field-call-feature-gated eval features() ---
+// Error: 6-15 cannot access field `data-cell` because the `a11y-extras` feature is not enabled
+// Hint: 6-15 try enabling the `a11y-extras` feature
+// Hint: 6-15 see https://typst.app/help/compiler-features for more details
+#pdf.data-cell()
diff --git a/tests/suite/scripting/import.typ b/tests/suite/scripting/import.typ
index 9abe548ee6ad..dcb2a8a571ef 100644
--- a/tests/suite/scripting/import.typ
+++ b/tests/suite/scripting/import.typ
@@ -227,7 +227,7 @@
 // Warning: 9-14 this import has no effect
 #import mymod
 // The name `chap1` is not bound.
-// Error: 2-7 unknown variable: chap1
+// Error: 2-7 unknown variable `chap1`
 #chap1
 
 --- import-module-nested eval ---
@@ -351,7 +351,7 @@ This is never reached.
 // Renaming does not import the old name (without items).
 #import "./modules/chap1.typ" as something
 #test(something.name, "Klaus")
-// Error: 7-12 unknown variable: chap1
+// Error: 7-12 unknown variable `chap1`
 #test(chap1.name, "Klaus")
 
 --- import-items-renamed-old-name eval ---
@@ -359,7 +359,7 @@ This is never reached.
 #import "./modules/chap1.typ" as something: name as other
 #test(other, "Klaus")
 #test(something.name, "Klaus")
-// Error: 7-12 unknown variable: chap1
+// Error: 7-12 unknown variable `chap1`
 #test(chap1.b, "Klaus")
 
 --- import-nested-invalid-type eval ---
@@ -492,6 +492,57 @@ This is never reached.
 // Error: 9-28 file not found (searched at tests/suite/scripting/#test/mypkg:1.0.0)
 #import "#test/mypkg:1.0.0": *
 
+--- import-feature-gated-item eval features() ---
+// Error: 14-18 cannot import `html` because the `html` feature is not enabled
+// Hint: 14-18 try enabling the `html` feature
+// Hint: 14-18 see https://typst.app/help/compiler-features for more details
+#import std: html
+
+--- import-feature-gated-field eval features() ---
+// Error: 18-31 cannot import `table-summary` because the `a11y-extras` feature is not enabled
+// Hint: 18-31 try enabling the `a11y-extras` feature
+// Hint: 18-31 see https://typst.app/help/compiler-features for more details
+#import std: pdf.table-summary
+
+--- import-deprecated-item eval ---
+// Warning: 15-25 this function is useless
+#import test: deprecated
+// Warning: 16-26 this value is useless
+#import check: deprecated
+
+--- import-feature-gated-value eval features() ---
+// Error: 16-21 cannot import `gated` because the `html` feature is not enabled
+// Hint: 16-21 try enabling the `html` feature
+// Hint: 16-21 see https://typst.app/help/compiler-features for more details
+#import check: gated
+
+--- import-feature-gated-field-of-type eval features() ---
+// Error: 15-20 cannot import `gated` because the `html` feature is not enabled
+// Hint: 15-20 try enabling the `html` feature
+// Hint: 15-20 see https://typst.app/help/compiler-features for more details
+#import test: gated
+
+--- import-feature-gated-wildcard-unused eval features() ---
+#let func() = {
+  import check: *
+}
+
+--- import-feature-gated-wildcard-shadowed eval features() ---
+#let gated() = {}
+#let func() = {
+  import check: *
+  gated()
+}
+#func()
+
+--- import-feature-gated-wildcard-used eval features() ---
+#let func() = {
+  import check: *
+  // Error: 3-8 unknown variable `gated`
+  gated()
+}
+#func()
+
 --- issue-7393-import-error-string-unclosed eval ---
 // More dedicated tests for this are in `crates/typst-syntax/src/reparser.rs`
 // Error: 1:9-3:1 unclosed string
diff --git a/tests/suite/scripting/include.typ b/tests/suite/scripting/include.typ
index f93db9459933..75fc32d49c7a 100644
--- a/tests/suite/scripting/include.typ
+++ b/tests/suite/scripting/include.typ
@@ -31,7 +31,7 @@
 #include "modules/chap1.typ"
 
 // The variables of the file should not appear in this scope.
-// Error: 2-6 unknown variable: name
+// Error: 2-6 unknown variable `name`
 #name
 
 --- include-semicolon-or-linebreak eval ---
diff --git a/tests/suite/scripting/ops.typ b/tests/suite/scripting/ops.typ
index 8fe0f3b17164..85c1922235db 100644
--- a/tests/suite/scripting/ops.typ
+++ b/tests/suite/scripting/ops.typ
@@ -267,6 +267,12 @@
 #test("sys" in std, true)
 #test("system" in std, false)
 
+--- ops-in-cannot-apply-to-elem eval ---
+// If this will become possible, the contains function needs to be adjusted to
+// consider feature gates.
+// Error: 3-25 cannot apply 'in' to string and function
+#("whatever" in document)
+
 --- ops-not-trailing eval ---
 // Error: 10 expected keyword `in`
 #("a" not)
@@ -498,7 +504,7 @@ $harpoon(i, dotless: #not false)$
 
 --- ops-assign-unknown-var-lhs eval ---
 #{
-  // Error: 3-6 unknown variable: a-1
+  // Error: 3-6 unknown variable `a-1`
   // Hint: 3-6 if you meant to use subtraction, try adding spaces around the minus sign: `a - 1`
   a-1 = 2
 }
@@ -509,17 +515,17 @@ $harpoon(i, dotless: #not false)$
   a = 1-a
   a = a -1
 
-  // Error: 7-10 unknown variable: a-1
+  // Error: 7-10 unknown variable `a-1`
   // Hint: 7-10 if you meant to use subtraction, try adding spaces around the minus sign: `a - 1`
   a = a-1
 }
 
 --- ops-assign-unknown-parenthesized-variable eval ---
-// Error: 4-5 unknown variable: x
+// Error: 4-5 unknown variable `x`
 #((x) = "")
 
 --- ops-assign-destructuring-unknown-variable eval ---
-// Error: 4-5 unknown variable: x
+// Error: 4-5 unknown variable `x`
 #((x,) = (1,))
 
 --- ops-assign-to-temporary eval ---
@@ -537,11 +543,11 @@ $harpoon(i, dotless: #not false)$
 #(not x = "a")
 
 --- ops-assign-to-invalid-binary-op eval ---
-// Error: 7-8 unknown variable: x
+// Error: 7-8 unknown variable `x`
 #(1 + x += 3)
 
 --- ops-assign-unknown-variable eval ---
-// Error: 3-4 unknown variable: z
+// Error: 3-4 unknown variable `z`
 #(z = 1)
 
 --- ops-assign-to-std-constant eval ---
diff --git a/tests/suite/scripting/recursion.typ b/tests/suite/scripting/recursion.typ
index f4319fc42db6..969c6562e8c1 100644
--- a/tests/suite/scripting/recursion.typ
+++ b/tests/suite/scripting/recursion.typ
@@ -14,7 +14,7 @@
 
 --- recursion-unnamed-invalid eval ---
 // Test with unnamed function.
-// Error: 17-18 unknown variable: f
+// Error: 17-18 unknown variable `f`
 #let f = (n) => f(n - 1)
 #f(10)
 
diff --git a/tests/suite/symbols/symbol.typ b/tests/suite/symbols/symbol.typ
index 27461f4e397e..c9e184869ea2 100644
--- a/tests/suite/symbols/symbol.typ
+++ b/tests/suite/symbols/symbol.typ
@@ -180,6 +180,9 @@
 // Warning: 3-7 `join` is deprecated, use `bowtie.big` instead
 $ join_x x = bowtie.big_x x $
 
+// Warning: 6-10 `join` is deprecated, use `bowtie.big` instead
+#sym.join
+
 --- symbol-modifier-deprecated paged ---
 // Warning: 6-9 `gt.tri` is deprecated, use `gt.closed` instead
 $ gt.tri $

From 0ba956b5733ea15cb3e87d86e9d28a7223683fa4 Mon Sep 17 00:00:00 2001
From: "Y.D.X." <73375426+YDX-2147483647@users.noreply.github.com>
Date: Mon, 17 Aug 2026 12:15:40 +0000
Subject: [PATCH 058/105] Let `cargo testit` use forward slashes for symlinks
 on Windows (#8754)

---
 tests/src/collect.rs | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/tests/src/collect.rs b/tests/src/collect.rs
index 3c95e313dcde..6fc48a523df7 100644
--- a/tests/src/collect.rs
+++ b/tests/src/collect.rs
@@ -359,7 +359,9 @@ impl TestOutput {
     /// The path at which the live output will be stored.
     pub fn hash_path(self, hash: HashedRef, name: &str) -> PathBuf {
         let ext = self.live_extension();
-        PathBuf::from(format!("{STORE_PATH}/by-hash/{hash}_{name}.{ext}"))
+        [STORE_PATH, "by-hash", format!("{hash}_{name}.{ext}").as_ref()]
+            .iter()
+            .collect()
     }
 
     /// The path at which a symlink to the [`Self::hash_path`] will be created

From a9815111e8b3fe561266a053b5c93e5965d9af22 Mon Sep 17 00:00:00 2001
From: tajpulo <97669430+tajpulo@users.noreply.github.com>
Date: Mon, 17 Aug 2026 12:43:15 +0000
Subject: [PATCH 059/105] Complete Esperanto translations (#8400)

Co-authored-by: tajpulo 
---
 crates/typst-library/translations/eo.txt | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/crates/typst-library/translations/eo.txt b/crates/typst-library/translations/eo.txt
index 2f8cb938b4d6..b78f7e4c6679 100644
--- a/crates/typst-library/translations/eo.txt
+++ b/crates/typst-library/translations/eo.txt
@@ -7,5 +7,5 @@ outline = Enhavo
 raw = Listado
 page = Paĝo
 footnote = Piednoto
-# email =
-# telephone =
+email = Retpoŝto
+telephone = Telefono

From ee532deacf7b0f5d4a071ff075a6048c00153b88 Mon Sep 17 00:00:00 2001
From: Malo <57839069+MDLC01@users.noreply.github.com>
Date: Mon, 17 Aug 2026 12:54:44 +0000
Subject: [PATCH 060/105] Improve French translation for `heading` (breaks
 grammar) (#8728)

---
 crates/typst-library/translations/fr.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/crates/typst-library/translations/fr.txt b/crates/typst-library/translations/fr.txt
index ff34c2978406..9e28c8bd3f24 100644
--- a/crates/typst-library/translations/fr.txt
+++ b/crates/typst-library/translations/fr.txt
@@ -2,7 +2,7 @@ figure = Fig.
 table = Tableau
 equation = Équation
 bibliography = Bibliographie
-heading = Chapitre
+heading = Section
 outline = Table des matières
 raw = Liste
 page = page

From 35417aa769ab9d699a60384f91082b09bc6ba421 Mon Sep 17 00:00:00 2001
From: Ilia <43654815+istudyatuni@users.noreply.github.com>
Date: Mon, 17 Aug 2026 13:11:06 +0000
Subject: [PATCH 061/105] Show key type instead of value in error when calling
 to-dict (#8731)

Co-authored-by: Laurenz 
---
 crates/typst-library/src/foundations/array.rs | 2 +-
 tests/suite/foundations/array.typ             | 6 +++++-
 2 files changed, 6 insertions(+), 2 deletions(-)

diff --git a/crates/typst-library/src/foundations/array.rs b/crates/typst-library/src/foundations/array.rs
index 8800e1b0733f..5464042c5bb6 100644
--- a/crates/typst-library/src/foundations/array.rs
+++ b/crates/typst-library/src/foundations/array.rs
@@ -1124,7 +1124,7 @@ impl Array {
                 })?;
                 if let [key, value] = pair.as_slice() {
                     let key = key.clone().cast::().map_err(|_| {
-                        eco_format!("expected key of type str, found {}", value.ty())
+                        eco_format!("expected key of type string, found {}", key.ty())
                     })?;
                     Ok((key, value.clone()))
                 } else {
diff --git a/tests/suite/foundations/array.typ b/tests/suite/foundations/array.typ
index e6910d914a5b..7c47459efb19 100644
--- a/tests/suite/foundations/array.typ
+++ b/tests/suite/foundations/array.typ
@@ -482,9 +482,13 @@
 #(("key",1,2),).to-dict()
 
 --- array-to-dict-bad-key-type eval ---
-// Error: 2-21 expected key of type str, found integer
+// Error: 2-21 expected key of type string, found integer
 #((1, 2),).to-dict()
 
+--- array-to-dict-none-key-type eval ---
+// Error: 2-24 expected key of type string, found none
+#((none, 2),).to-dict()
+
 --- array-zip-positional-and-named-argument eval ---
 // Error: 13-30 unexpected argument: val
 #().zip((), val: "applicable")

From fdfb8da09a7b3557fd71c6131713b4c8a8d360a0 Mon Sep 17 00:00:00 2001
From: Ian Wrzesinski 
Date: Fri, 24 Jul 2026 13:34:04 -0400
Subject: [PATCH 062/105] Fix `luma` argument handling and improve doc
 consistency

---
 crates/typst-library/src/visualize/color.rs | 11 +++++------
 tests/suite/visualize/color.typ             | 20 ++++++++++++++++++--
 2 files changed, 23 insertions(+), 8 deletions(-)

diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs
index d413a7da8a28..e6e2544ff2a7 100644
--- a/crates/typst-library/src/visualize/color.rs
+++ b/crates/typst-library/src/visualize/color.rs
@@ -326,8 +326,8 @@ impl Color {
 
     /// Create a grayscale color.
     ///
-    /// A grayscale color is represented internally by a single `lightness`
-    /// component.
+    /// A grayscale color is represented by `lightness` (@ratio) and `alpha`
+    /// (@ratio) components.
     ///
     /// These components are also available using the
     /// @color.components[`components`] method.
@@ -348,7 +348,7 @@ impl Color {
         alpha: RatioComponent,
         /// Alternatively: The color to convert to grayscale.
         ///
-        /// If this is given, the `lightness` should not be given.
+        /// If this is given, the individual components should not be given.
         #[external]
         color: Color,
     ) -> SourceResult {
@@ -356,11 +356,10 @@ impl Color {
             if let Some(color) = args.find::()? {
                 color.to_luma()
             } else {
-                let Component(gray) =
-                    args.expect("gray component").unwrap_or(Component(Ratio::one()));
+                let Component(l) = args.expect("lightness component")?;
                 let RatioComponent(alpha) =
                     args.eat()?.unwrap_or(RatioComponent(Ratio::one()));
-                Luma::new(gray.get() as f32, alpha.get() as f32)
+                Luma::new(l.get() as f32, alpha.get() as f32)
             },
         )))
     }
diff --git a/tests/suite/visualize/color.typ b/tests/suite/visualize/color.typ
index 05b1fd15b206..22fba345d125 100644
--- a/tests/suite/visualize/color.typ
+++ b/tests/suite/visualize/color.typ
@@ -206,9 +206,25 @@
 // Error: 21-26 expected integer or ratio, found boolean
 #rgb(10%, 20%, 30%, false)
 
+--- color-luma-empty-arguments eval ---
+// Error: 7-13 missing argument: lightness component
+#test(luma(), white)
+
+--- color-luma-invalid-alpha eval ---
+// Error: 7-16 expected integer or ratio, found string
+#luma("invalid")
+
+--- color-luma-invalid-lightness eval ---
+// Error: 12-21 expected ratio, found string
+#luma(10%, "invalid")
+
 --- color-luma-unexpected-argument eval ---
-// Error: 10-20 unexpected argument: key
-#luma(1, key: "val")
+// Error: 12-22 unexpected argument: key
+#luma(255, key: "val")
+
+--- color-luma-unexpected-pos-argument eval ---
+// Error: 17-22 unexpected argument
+#luma(25%, 50%, "val")
 
 --- color-mix-bad-amount-type eval ---
 // Error: 12-24 expected float or ratio, found string

From afe812ed7b1d4e4789e39b6d95c2c83b1723295a Mon Sep 17 00:00:00 2001
From: Ian Wrzesinski 
Date: Fri, 24 Jul 2026 13:34:04 -0400
Subject: [PATCH 063/105] Allow integers for the `luma` alpha component

Since lightness also allows integers via `Component` and the other
color spaces that take `Component` always use it for `alpha`.
---
 crates/typst-library/src/visualize/color.rs | 5 ++---
 tests/suite/visualize/color.typ             | 5 ++++-
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs
index e6e2544ff2a7..4b4b287d1219 100644
--- a/crates/typst-library/src/visualize/color.rs
+++ b/crates/typst-library/src/visualize/color.rs
@@ -345,7 +345,7 @@ impl Color {
         lightness: Component,
         /// The alpha component.
         #[external]
-        alpha: RatioComponent,
+        alpha: Component,
         /// Alternatively: The color to convert to grayscale.
         ///
         /// If this is given, the individual components should not be given.
@@ -357,8 +357,7 @@ impl Color {
                 color.to_luma()
             } else {
                 let Component(l) = args.expect("lightness component")?;
-                let RatioComponent(alpha) =
-                    args.eat()?.unwrap_or(RatioComponent(Ratio::one()));
+                let Component(alpha) = args.eat()?.unwrap_or(Component(Ratio::one()));
                 Luma::new(l.get() as f32, alpha.get() as f32)
             },
         )))
diff --git a/tests/suite/visualize/color.typ b/tests/suite/visualize/color.typ
index 22fba345d125..ef33c479cfd1 100644
--- a/tests/suite/visualize/color.typ
+++ b/tests/suite/visualize/color.typ
@@ -172,6 +172,9 @@
 #test-components(rgb(1, 2, 3, 4), (0.39%, 0.78%, 1.18%, 1.57%))
 #test-components(luma(40), (15.69%, 100%))
 #test-components(luma(40, 50%), (15.69%, 50%))
+#test-components(luma(40, 127), (15.69%, 49.8%))
+#test-components(luma(40, 0), (15.69%, 0%))
+#test-components(luma(40, 255), (15.69%, 100%))
 #test-components(cmyk(4%, 5%, 6%, 7%), (4%, 5%, 6%, 7%), has-alpha: false)
 #test-components(oklab(10%, 0.2, 0.4), (10%, 0.2, 0.4, 100%))
 #test-components(oklch(10%, 0.2, 90deg), (10%, 0.2, 90deg, 100%))
@@ -215,7 +218,7 @@
 #luma("invalid")
 
 --- color-luma-invalid-lightness eval ---
-// Error: 12-21 expected ratio, found string
+// Error: 12-21 expected integer or ratio, found string
 #luma(10%, "invalid")
 
 --- color-luma-unexpected-argument eval ---

From 65ee107168340bfb5b780e18b8f776355140661d Mon Sep 17 00:00:00 2001
From: Ian Wrzesinski 
Date: Tue, 18 Aug 2026 18:41:57 -0400
Subject: [PATCH 064/105] Add hint to try `white` or `luma(100%)` for empty
 luma args

---
 crates/typst-library/src/visualize/color.rs | 13 ++++++++++++-
 tests/suite/visualize/color.typ             |  1 +
 2 files changed, 13 insertions(+), 1 deletion(-)

diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs
index 4b4b287d1219..091f6b3fe9c1 100644
--- a/crates/typst-library/src/visualize/color.rs
+++ b/crates/typst-library/src/visualize/color.rs
@@ -356,7 +356,18 @@ impl Color {
             if let Some(color) = args.find::()? {
                 color.to_luma()
             } else {
-                let Component(l) = args.expect("lightness component")?;
+                let missing_lightness = args.is_empty();
+                let Component(l) =
+                    args.expect("lightness component").map_err(|mut err| {
+                        if missing_lightness {
+                            // Before 0.16, lightness accidentally defaulted to
+                            // `100%`, so this should help with breakage. I
+                            // could not find a cleaner way to add the hint >:(
+                            let last = err.make_mut().last_mut().unwrap();
+                            last.hint("try writing `white` or `luma(100%)`");
+                        }
+                        err
+                    })?;
                 let Component(alpha) = args.eat()?.unwrap_or(Component(Ratio::one()));
                 Luma::new(l.get() as f32, alpha.get() as f32)
             },
diff --git a/tests/suite/visualize/color.typ b/tests/suite/visualize/color.typ
index ef33c479cfd1..6d6fbef93c9f 100644
--- a/tests/suite/visualize/color.typ
+++ b/tests/suite/visualize/color.typ
@@ -211,6 +211,7 @@
 
 --- color-luma-empty-arguments eval ---
 // Error: 7-13 missing argument: lightness component
+// Hint: 7-13 try writing `white` or `luma(100%)`
 #test(luma(), white)
 
 --- color-luma-invalid-alpha eval ---

From 22f75f2c6a34df820220614c540e5805b64cef66 Mon Sep 17 00:00:00 2001
From: Ian Wrzesinski 
Date: Tue, 18 Aug 2026 18:41:57 -0400
Subject: [PATCH 065/105] Use `if let` in `Args::missing_argument`

---
 crates/typst-library/src/foundations/args.rs | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/crates/typst-library/src/foundations/args.rs b/crates/typst-library/src/foundations/args.rs
index f3e204638948..97a13df54022 100644
--- a/crates/typst-library/src/foundations/args.rs
+++ b/crates/typst-library/src/foundations/args.rs
@@ -160,8 +160,9 @@ impl Args {
     /// The error message for missing arguments.
     fn missing_argument(&self, what: &str) -> SourceDiagnostic {
         for item in &self.items {
-            let Some(name) = item.name.as_deref() else { continue };
-            if name == what {
+            if let Some(name) = item.name.as_deref()
+                && name == what
+            {
                 return error!(
                     item.span,
                     "the argument `{what}` is positional";

From 705ae32400ba9f12004b3498e4b6629d514a401a Mon Sep 17 00:00:00 2001
From: Martin Haug 
Date: Mon, 24 Aug 2026 16:10:10 +0000
Subject: [PATCH 066/105] Pin Docker base layers to specific tags (#8753)

---
 Dockerfile | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/Dockerfile b/Dockerfile
index 61dcc87b1b92..c2be1c93397a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
-FROM --platform=$BUILDPLATFORM tonistiigi/xx AS xx
-FROM --platform=$BUILDPLATFORM rust:alpine AS build
+FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0 AS xx
+FROM --platform=$BUILDPLATFORM rust:1.95.0-alpine3.23 AS build
 
 COPY --from=xx / /
 
@@ -24,7 +24,7 @@ RUN --mount=type=cache,target=/root/.cargo/git/db \
     cp target/$(xx-cargo --print-target-triple)/release/typst target/release/typst && \
     xx-verify target/release/typst
 
-FROM alpine:latest
+FROM alpine:3.23
 ARG CREATED
 ARG REVISION
 

From a9691702c1fedb94f53f51bd85cad659d0335454 Mon Sep 17 00:00:00 2001
From: Leonhard Masche 
Date: Mon, 24 Aug 2026 16:11:02 +0000
Subject: [PATCH 067/105] Fix comment formatting for color option (#8774)

---
 crates/typst-cli/src/args.rs | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs
index 2d038a38fa37..5d9579dcb8c9 100644
--- a/crates/typst-cli/src/args.rs
+++ b/crates/typst-cli/src/args.rs
@@ -66,7 +66,7 @@ pub struct CliArguments {
     #[command(subcommand)]
     pub command: Command,
 
-    /// Whether to use color. When set to `auto` if the terminal to supports it.
+    /// Whether to use color. When set to `auto`, uses color if the terminal supports it.
     #[clap(long, default_value_t = ColorChoice::Auto, default_missing_value = "always")]
     pub color: ColorChoice,
 

From 2a9d4c0d376e3b01afe9fcbf4e5e0bddda43d0ae Mon Sep 17 00:00:00 2001
From: Malo <57839069+MDLC01@users.noreply.github.com>
Date: Mon, 24 Aug 2026 16:11:11 +0000
Subject: [PATCH 068/105] Fix spacing for single-argument functions in docs
 (#8775)

---
 docs/assets/docs.css | 10 +---------
 1 file changed, 1 insertion(+), 9 deletions(-)

diff --git a/docs/assets/docs.css b/docs/assets/docs.css
index 1059482ad12b..0eaef81362db 100644
--- a/docs/assets/docs.css
+++ b/docs/assets/docs.css
@@ -1416,15 +1416,7 @@ h4 .additional-info .pill + .pill {
 }
 
 .single-arg .arguments .overview-param {
-  display: inline-flex;
-  align-items: baseline;
-  flex-wrap: wrap;
-  gap: 4px;
-  margin-inline: 2px;
-}
-
-.single-arg .arguments .pill + .pill {
-  margin-inline-start: 0px;
+  display: inline;
 }
 
 .arguments {

From 3e2841e4913c31be5362d6d324938e44fb16ae23 Mon Sep 17 00:00:00 2001
From: "Y.D.X." <73375426+YDX-2147483647@users.noreply.github.com>
Date: Mon, 24 Aug 2026 16:13:29 +0000
Subject: [PATCH 069/105] Improve line breaks for the list of raw syntaxes in
 PDF docs (#8750)

Co-authored-by: Laurenz 
---
 crates/typst-library/src/text/raw.rs | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs
index ea23eb66b0c6..88018f622adb 100644
--- a/crates/typst-library/src/text/raw.rs
+++ b/crates/typst-library/src/text/raw.rs
@@ -340,7 +340,13 @@ pub struct RawElem {
     ///     table.header[Name][Tags],
     ///     ..stdx.raw-langs.map(((name, tokens)) => (
     ///       name,
-    ///       tokens.map(raw).join[, ],
+    ///       // Some tokens start with `.`. For example, `.env`. The Unicode
+    ///       // Line Breaking Algorithm forbids breaking before `.`, even after
+    ///       // spaces. Therefore, we add zero-width spaces to provide
+    ///       // additional break opportunities. Note that
+    ///       // https://github.com/typst/typst/issues/5317 might change the
+    ///       // situation.
+    ///       tokens.map(raw).join[, #sym.zws],
     ///     )).flatten()
     ///   )
     /// )

From 41e955d2c7fc045b2301f39563d5ca80a63fc251 Mon Sep 17 00:00:00 2001
From: Carl Kuhlemann <70340234+linux-user36@users.noreply.github.com>
Date: Tue, 25 Aug 2026 15:39:30 +0000
Subject: [PATCH 070/105] Use `` for authors in HTML output
 (#8758)

Co-authored-by: Carl Kuhlemann 
Co-authored-by: Laurenz 
---
 crates/typst-html/src/document.rs                  | 6 +++---
 tests/ref/bundle/document-properties-in-bundle.txt | 2 +-
 tests/ref/html/hashes.txt                          | 2 ++
 tests/suite/model/document.typ                     | 8 ++++++++
 4 files changed, 14 insertions(+), 4 deletions(-)

diff --git a/crates/typst-html/src/document.rs b/crates/typst-html/src/document.rs
index 15eebdcb3181..e55eea7ba76b 100644
--- a/crates/typst-html/src/document.rs
+++ b/crates/typst-html/src/document.rs
@@ -340,11 +340,11 @@ fn head_element(info: &DocumentInfo) -> HtmlElement {
         );
     }
 
-    if !info.author.is_empty() {
+    for author in &info.author {
         children.push(
             HtmlElement::new(tag::meta)
-                .with_attr(attr::name, "authors")
-                .with_attr(attr::content, info.author.join(", "))
+                .with_attr(attr::name, "author")
+                .with_attr(attr::content, author)
                 .into(),
         );
     }
diff --git a/tests/ref/bundle/document-properties-in-bundle.txt b/tests/ref/bundle/document-properties-in-bundle.txt
index 3c5fe9f97845..498e50c769be 100644
--- a/tests/ref/bundle/document-properties-in-bundle.txt
+++ b/tests/ref/bundle/document-properties-in-bundle.txt
@@ -1 +1 @@
-4416a5e8d5e907f109b38ff116c8d7a4 hi.html
+da77501b2056df22ed5cfba32aab3043 hi.html
diff --git a/tests/ref/html/hashes.txt b/tests/ref/html/hashes.txt
index bb2586aae71d..4adaea2ee2a0 100644
--- a/tests/ref/html/hashes.txt
+++ b/tests/ref/html/hashes.txt
@@ -41,6 +41,8 @@ ffa25d90e2baac1e02b03a60cae89720 block-html-block
 e87734ba4dba7fed32967be69484478c col-gutter-table
 e87734ba4dba7fed32967be69484478c col-row-gutter-table
 c3adb00220cf56685a91e1bd4d93f3b4 divider-html
+51bc0feb18dfc54b51463fa6374d9a21 document-multiple-authors-html
+ec72df663ab6b4fe6aedaec1ab3f2165 document-multiple-keywords-html
 4131a1789df95f853768971f54e850a9 enum-par
 013e6642f1140f60b83d7ca64ca77c87 enum-start
 dfd6054882467753666c1ebe54b81a0d footnote-basic
diff --git a/tests/suite/model/document.typ b/tests/suite/model/document.typ
index a6a6bdafad07..1c20a926a877 100644
--- a/tests/suite/model/document.typ
+++ b/tests/suite/model/document.typ
@@ -41,6 +41,14 @@ Hello
 // Error: 11-15 path `".."` would escape the bundle root
 #document("..", format: "pdf")[Root?]
 
+--- document-multiple-authors-html html ---
+// Should emit multiple `` tags.
+#set document(author: ("John Doe", "Jane Doe"))
+
+--- document-multiple-keywords-html html ---
+// Should emit a single `` tag.
+#set document(keywords: ("foo", "bar"))
+
 --- document-weird-extension bundle ---
 // The hashes of both files should match.
 #document("normal.pdf")[A PDF]

From 23ed3c3fc1d4c107f769c3ca5eb05cfee688e4e7 Mon Sep 17 00:00:00 2001
From: Tobias Schmitz 
Date: Wed, 26 Aug 2026 08:56:50 +0000
Subject: [PATCH 071/105] Update correct tag tree jump progressions when
 splitting outer group (#8778)

---
 crates/typst-pdf/src/tags/tree/build.rs | 2 +-
 tests/ref/pdftags/hashes.txt            | 1 +
 tests/suite/pdftags/link.typ            | 6 ++++++
 3 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/crates/typst-pdf/src/tags/tree/build.rs b/crates/typst-pdf/src/tags/tree/build.rs
index 5e54c621762f..1033f1470e39 100644
--- a/crates/typst-pdf/src/tags/tree/build.rs
+++ b/crates/typst-pdf/src/tags/tree/build.rs
@@ -772,7 +772,7 @@ fn split_outer_group(
         }
 
         // Update progressions to jump into the inner entry instead.
-        let prev = tree.progressions[entry.prog_idx as usize];
+        let prev = entry.id;
         for prog in &mut tree.progressions[entry.prog_idx as usize..] {
             if *prog == prev {
                 *prog = nested;
diff --git a/tests/ref/pdftags/hashes.txt b/tests/ref/pdftags/hashes.txt
index 32af833eeb0e..b1f98590071c 100644
--- a/tests/ref/pdftags/hashes.txt
+++ b/tests/ref/pdftags/hashes.txt
@@ -59,6 +59,7 @@ cae4c2653cf68f0c43c1619afea2c424 link-basic
 5c6b8b980f727c417326686acb7f854c link-tags-contact-prefix
 319c50f5b21c31ff13569f4b5903b7d6 link-tags-heading-with-numbering
 8a18e918e15ed8260bef7a4aee86e3a4 link-tags-heading-without-numbering
+091cb4a5621d1ce40d1b351458142384 link-tags-nesting-across-pars
 1cd0843343eaeee4d8429b29e04e298f link-tags-non-refable-location
 ceff1a5f851fa3bc1763a37a2931dcce link-tags-position
 a63593f98e18612ca4f9ac1515f6833c link-tags-with-parbreak
diff --git a/tests/suite/pdftags/link.typ b/tests/suite/pdftags/link.typ
index a9cb499ac2e1..4a9a51a12702 100644
--- a/tests/suite/pdftags/link.typ
+++ b/tests/suite/pdftags/link.typ
@@ -77,3 +77,9 @@ Look #link("https://github.com/typst/typst")[this #parbreak() thing].
 // of the next line, which can cause issues with links in PDF.
 #set text(lang: "cs", hyphenate: true)
 #block(width: 0.9cm)[#link("x")[test-]ing]
+
+--- link-tags-nesting-across-pars pdftags ---
+This #link("https://outer.org")[is a split
+#link("https://inner.org")[ multi paragraph link.
+
+Thanks for the] multi attention.] Okay!

From 00e8c3cd2c1d204f6e7ebba41a83254772b0412c Mon Sep 17 00:00:00 2001
From: Tom Planche 
Date: Wed, 26 Aug 2026 09:12:14 +0000
Subject: [PATCH 072/105] Require `=` when passing a viewer to `--open` (#8763)

---
 crates/typst-cli/src/args.rs | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs
index 5d9579dcb8c9..bf7185d29fb2 100644
--- a/crates/typst-cli/src/args.rs
+++ b/crates/typst-cli/src/args.rs
@@ -381,7 +381,10 @@ pub struct CompileArgs {
 
     /// Opens the output file with the default viewer or a specific program
     /// after compilation. Ignored if output is stdout.
-    #[arg(long = "open", value_name = "VIEWER")]
+    ///
+    /// When passing a specific program, the name must be attached with
+    /// an equals sign (`--open=VIEWER`).
+    #[arg(long = "open", value_name = "VIEWER", require_equals = true)]
     pub open: Option>,
 
     /// Produces performance timings of the compilation process. (experimental)

From fe1281132e5a69d43325aed390f9946e901fc383 Mon Sep 17 00:00:00 2001
From: Malo <57839069+MDLC01@users.noreply.github.com>
Date: Wed, 26 Aug 2026 10:50:45 +0000
Subject: [PATCH 073/105] Order emoji in CLDR order in the documentation
 (#8282)

---
 Cargo.lock                                 | 160 +++++++++++++--------
 Cargo.toml                                 |   2 +-
 docs/Cargo.toml                            |   1 +
 docs/content/reference/library/symbols.typ |  23 ++-
 docs/src/reflect.rs                        |  15 ++
 docs/src/world.rs                          |   1 +
 6 files changed, 133 insertions(+), 69 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index f2e5160ea070..73e4db0647cd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -238,7 +238,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -385,7 +385,7 @@ dependencies = [
  "heck",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -459,7 +459,7 @@ dependencies = [
  "nom",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -495,7 +495,7 @@ checksum = "a3c400139ba1389ef9e20ad2d87cda68b437a66483aa0da616bdf2cea7413853"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -637,7 +637,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -672,7 +672,7 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -1150,14 +1150,14 @@ dependencies = [
 
 [[package]]
 name = "icu_collator"
-version = "2.2.0"
+version = "2.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b521b92a2666061ddda902769d8a4cf730b5c9529a845cc1b69770b12a6c9a71"
+checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda"
 dependencies = [
  "icu_collator_data",
  "icu_collections",
- "icu_locale",
  "icu_locale_core",
+ "icu_locale_fallback",
  "icu_normalizer",
  "icu_properties",
  "icu_provider",
@@ -1169,15 +1169,15 @@ dependencies = [
 
 [[package]]
 name = "icu_collator_data"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "038ed8e5817f2059c2f3efb0945ba78d060d3d25e8f1a1bea5139f821a21a2f0"
+checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368"
 
 [[package]]
 name = "icu_collections"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
 dependencies = [
  "displaydoc",
  "potential_utf",
@@ -1190,24 +1190,24 @@ dependencies = [
 
 [[package]]
 name = "icu_locale"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26"
+checksum = "250ec6d4ac288584d1d0ba29dbfad7737635b7b0e822441f4026a523e3fdb147"
 dependencies = [
  "icu_collections",
  "icu_locale_core",
  "icu_locale_data",
+ "icu_locale_fallback",
  "icu_provider",
- "potential_utf",
  "tinystr",
  "zerovec",
 ]
 
 [[package]]
 name = "icu_locale_core"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
 dependencies = [
  "displaydoc",
  "litemap",
@@ -1219,15 +1219,35 @@ dependencies = [
 
 [[package]]
 name = "icu_locale_data"
-version = "2.2.0"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea"
+
+[[package]]
+name = "icu_locale_fallback"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
+dependencies = [
+ "icu_locale_core",
+ "icu_locale_fallback_data",
+ "icu_provider",
+ "potential_utf",
+ "tinystr",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_fallback_data"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993"
+checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
 
 [[package]]
 name = "icu_normalizer"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
 dependencies = [
  "icu_collections",
  "icu_normalizer_data",
@@ -1242,16 +1262,17 @@ dependencies = [
 
 [[package]]
 name = "icu_normalizer_data"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
 
 [[package]]
 name = "icu_properties"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
 dependencies = [
+ "displaydoc",
  "icu_collections",
  "icu_locale_core",
  "icu_properties_data",
@@ -1263,15 +1284,15 @@ dependencies = [
 
 [[package]]
 name = "icu_properties_data"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
 
 [[package]]
 name = "icu_provider"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428"
 dependencies = [
  "displaydoc",
  "icu_locale_core",
@@ -1301,26 +1322,27 @@ dependencies = [
 
 [[package]]
 name = "icu_segmenter"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964"
+checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
 dependencies = [
  "core_maths",
  "icu_collections",
- "icu_locale",
+ "icu_locale_fallback",
  "icu_provider",
  "icu_segmenter_data",
  "potential_utf",
  "serde",
+ "smallvec",
  "utf8_iter",
  "zerovec",
 ]
 
 [[package]]
 name = "icu_segmenter_data"
-version = "2.2.0"
+version = "2.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f"
+checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
 
 [[package]]
 name = "idna"
@@ -1860,7 +1882,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -1935,7 +1957,7 @@ dependencies = [
  "by_address",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -2064,7 +2086,7 @@ dependencies = [
  "phf_shared 0.13.1",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -2580,7 +2602,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -2779,7 +2801,7 @@ dependencies = [
  "heck",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -2815,6 +2837,17 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
 [[package]]
 name = "synstructure"
 version = "0.13.1"
@@ -2823,7 +2856,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -2924,7 +2957,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -2998,9 +3031,9 @@ dependencies = [
 
 [[package]]
 name = "tinystr"
-version = "0.8.3"
+version = "0.8.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
 dependencies = [
  "displaydoc",
  "serde_core",
@@ -3199,13 +3232,14 @@ dependencies = [
  "ecow",
  "either",
  "heck",
+ "icu_collator",
  "open",
  "proc-macro2",
  "rayon",
  "rustc-hash",
  "serde",
  "serde_json",
- "syn",
+ "syn 2.0.98",
  "typst",
  "typst-assets",
  "typst-bundle",
@@ -3444,7 +3478,7 @@ dependencies = [
  "heck",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -3664,7 +3698,7 @@ checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5"
 dependencies = [
  "proc-macro-hack",
  "quote",
- "syn",
+ "syn 2.0.98",
  "unic-langid-impl",
 ]
 
@@ -3930,7 +3964,7 @@ dependencies = [
  "log",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
  "wasm-bindgen-shared",
 ]
 
@@ -3952,7 +3986,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
  "wasm-bindgen-backend",
  "wasm-bindgen-shared",
 ]
@@ -4167,9 +4201,9 @@ checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
 
 [[package]]
 name = "writeable"
-version = "0.6.3"
+version = "0.6.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
 
 [[package]]
 name = "wyz"
@@ -4223,9 +4257,9 @@ dependencies = [
 
 [[package]]
 name = "yoke"
-version = "0.8.2"
+version = "0.8.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
 dependencies = [
  "stable_deref_trait",
  "yoke-derive",
@@ -4240,7 +4274,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
  "synstructure",
 ]
 
@@ -4262,7 +4296,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
 ]
 
 [[package]]
@@ -4282,15 +4316,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.98",
  "synstructure",
 ]
 
 [[package]]
 name = "zerotrie"
-version = "0.2.4"
+version = "0.2.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
 dependencies = [
  "displaydoc",
  "litemap",
@@ -4302,9 +4336,9 @@ dependencies = [
 
 [[package]]
 name = "zerovec"
-version = "0.11.6"
+version = "0.11.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
 dependencies = [
  "serde",
  "yoke",
@@ -4314,13 +4348,13 @@ dependencies = [
 
 [[package]]
 name = "zerovec-derive"
-version = "0.11.3"
+version = "0.11.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 3.0.3",
 ]
 
 [[package]]
diff --git a/Cargo.toml b/Cargo.toml
index d8e46cddd8eb..9b93065c7b74 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -67,7 +67,7 @@ hayro-svg = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581
 hayro-syntax = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea15497effb2a884fb1608" }
 heck = "0.5"
 hypher = "0.1.4"
-icu_collator = { version = "2.2.0" }
+icu_collator = { version = "2.3.1" }
 icu_properties = { version = "2.2.0", features = ["serde"] }
 icu_provider = { version = "2.2.0", features = ["sync"] }
 icu_provider_blob = "2.2.0"
diff --git a/docs/Cargo.toml b/docs/Cargo.toml
index 783831bc9667..bd29bb5d007d 100644
--- a/docs/Cargo.toml
+++ b/docs/Cargo.toml
@@ -27,6 +27,7 @@ comemo = { workspace = true }
 ecow = { workspace = true }
 either = { workspace = true }
 heck = { workspace = true }
+icu_collator = { workspace = true }
 open = { workspace = true }
 proc-macro2 = { workspace = true, features = ["span-locations"] }
 rayon = { workspace = true }
diff --git a/docs/content/reference/library/symbols.typ b/docs/content/reference/library/symbols.typ
index be20b426879a..63c18e713657 100644
--- a/docs/content/reference/library/symbols.typ
+++ b/docs/content/reference/library/symbols.typ
@@ -227,11 +227,14 @@
         .map(((variant, ..)) => complete(variant))
         .filter(v => v != full-name)
 
-      entries.push(symbol-entry(
-        full-name,
+      entries.push((
         value,
-        deprecation,
-        alternates,
+        symbol-entry(
+          full-name,
+          value,
+          deprecation,
+          alternates,
+        ),
       ))
     }
   }
@@ -256,7 +259,17 @@
 // A list / grid of symbols with their names.
 #let symbol-name-list(mod, emoji: false) = {
   let entries = symbol-list-entries(mod, none)
-  symbol-list(entries, emoji: emoji)
+  if emoji {
+    // Order entries in CLDR order.
+    entries = entries.sorted(
+      key: ((value, entry)) => value,
+      by: stdx.emoji-ordering,
+    )
+  }
+  symbol-list(
+    entries.map(((_, entry)) => entry),
+    emoji: emoji,
+  )
 }
 
 // A list / grid of symbols with their shorthands.
diff --git a/docs/src/reflect.rs b/docs/src/reflect.rs
index ae2cf630df58..73da40db0dcc 100644
--- a/docs/src/reflect.rs
+++ b/docs/src/reflect.rs
@@ -2,11 +2,15 @@
 //!
 //! Cooperates with `docs/components/reflect.typ`.
 
+use std::cmp::Ordering;
 use std::path::Path;
 use std::sync::LazyLock;
 
 use ecow::EcoString;
 use heck::ToTitleCase;
+use icu_collator::options::{CollatorOptions, Strength};
+use icu_collator::preferences::CollationType;
+use icu_collator::{Collator, CollatorPreferences};
 use rustc_hash::FxHashMap;
 use typst::diag::bail;
 use typst::foundations::{
@@ -211,6 +215,17 @@ pub fn unicode_name(c: Cluster) -> Option {
     })
 }
 
+/// Returns `{true}` if two emoji are in order.
+#[func]
+pub fn emoji_ordering(left: EcoString, right: EcoString) -> bool {
+    let mut preferences = CollatorPreferences::default();
+    preferences.collation_type = Some(CollationType::Emoji);
+    let mut options = CollatorOptions::default();
+    options.strength = Some(Strength::Quaternary);
+    let collator = Collator::try_new(preferences, options).unwrap();
+    collator.compare(&left, &right) != Ordering::Greater
+}
+
 /// Returns the name of a character in LaTeX.
 #[func]
 pub fn latex_name(c: Cluster) -> Option {
diff --git a/docs/src/world.rs b/docs/src/world.rs
index 1110ca399c63..57d9f688feed 100644
--- a/docs/src/world.rs
+++ b/docs/src/world.rs
@@ -223,6 +223,7 @@ fn stdx_module(is_dev_version: bool) -> Module {
     scope.define_func::();
     scope.define_func::();
     scope.define_func::();
+    scope.define_func::();
     scope.define_func::();
     scope.define_func::();
     scope.define("commit", typst_utils::version().commit());

From 6996868712fedaa70482eb6405c1e580962cd6fd Mon Sep 17 00:00:00 2001
From: Malo <57839069+MDLC01@users.noreply.github.com>
Date: Wed, 26 Aug 2026 12:55:15 +0000
Subject: [PATCH 074/105] Improve results when searching for prefixes in symbol
 search (#8784)

---
 docs/assets/docs.js | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/docs/assets/docs.js b/docs/assets/docs.js
index 577ef2dda25b..ad0d66d104dd 100644
--- a/docs/assets/docs.js
+++ b/docs/assets/docs.js
@@ -1188,13 +1188,16 @@ function normalizedEditDistance(left, right) {
   }
 
   const INSERTION_COST = 0.7;
+  // We use a lower cost for inserting at the end.
+  // https://github.com/typst/typst/pull/8784
+  const TAIL_INSERTION_COST = 0.5;
   const DELETION_COST = 1.0;
   const SUBSTITUTION_COST = Infinity;
 
   let previous = Array(rightLength + 1).fill(0.0);
   let current = Array(rightLength + 1).fill(0.0);
   for (let j = 0; j <= rightLength; j++) {
-    current[j] = j * INSERTION_COST;
+    current[j] = j * TAIL_INSERTION_COST;
   }
   for (let i = 1; i <= leftLength; i++) {
     const tmp = previous;
@@ -1203,7 +1206,8 @@ function normalizedEditDistance(left, right) {
     current[0] = i * DELETION_COST;
     for (let j = 1; j <= rightLength; j++) {
       current[j] = Math.min(
-        current[j - 1] + INSERTION_COST,
+        current[j - 1] +
+          (i == leftLength ? TAIL_INSERTION_COST : INSERTION_COST),
         previous[j] + DELETION_COST,
         previous[j - 1] +
           (left[i - 1] === right[j - 1] ? 0.0 : SUBSTITUTION_COST),

From b0f307c3c27f9bc8b86d064e6a3f1904ccd2d2f8 Mon Sep 17 00:00:00 2001
From: Laurenz 
Date: Wed, 26 Aug 2026 12:55:52 +0000
Subject: [PATCH 075/105] Bump Rust in CI to 1.98 (#8785)

---
 .github/workflows/ci.yml                       | 12 ++++++------
 .github/workflows/release.yml                  |  4 ++--
 Cargo.toml                                     |  1 +
 Dockerfile                                     |  2 +-
 crates/typst-layout/src/grid/layouter.rs       |  5 ++---
 crates/typst-layout/src/math/scripts.rs        |  1 +
 crates/typst-library/src/foundations/styles.rs |  5 +----
 crates/typst-library/src/text/lang.rs          |  2 +-
 crates/typst-pdf/src/shape.rs                  |  5 ++---
 9 files changed, 17 insertions(+), 20 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dd12d26fc6a5..25ab4d3b6692 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -49,7 +49,7 @@ jobs:
           sudo dpkg --add-architecture i386
           sudo apt update
           sudo apt install -y gcc-multilib g++-multilib libssl-dev:i386 pkg-config:i386
-      - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
+      - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0
         with:
           targets: ${{ matrix.bits == 32 && 'i686-unknown-linux-gnu' || '' }}
       - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
@@ -92,7 +92,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
-      - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
+      - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0
         with:
           components: clippy, rustfmt
       - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
@@ -107,7 +107,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
-      - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
+      - uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0
       - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
       - run: cargo docit compile --format website --deny-warnings
       - run: cargo docit compile --format pdf --deny-warnings
@@ -117,7 +117,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
-      - uses: dtolnay/rust-toolchain@b5826f8eb73f40ac7c19ef631dce17a9680f8129 # 1.92.0
+      - uses: dtolnay/rust-toolchain@87eb139fed4b08a67bd1fa429a21d1f5d523e03e # 1.92.0
       - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
       - run: cargo check --workspace
 
@@ -126,7 +126,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
-      - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master
+      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
         with:
           toolchain: nightly-2025-10-28
       - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
@@ -138,7 +138,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
-      - uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # master
+      - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master
         with:
           components: miri
           toolchain: nightly-2025-10-28
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cb27e12e3bb3..26e6568884e0 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -46,7 +46,7 @@ jobs:
         uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
 
       - name: Install Rust
-        uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
+        uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0
         with:
           target: ${{ matrix.target }}
 
@@ -99,7 +99,7 @@ jobs:
         uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
 
       - name: Install Rust
-        uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0
+        uses: dtolnay/rust-toolchain@f8be11a05b1d4f3fcebe6410cc16743212b999b0 # 1.98.0
 
       - name: Compile documentation
         run: cargo docit compile --format pdf --release
diff --git a/Cargo.toml b/Cargo.toml
index 9b93065c7b74..6f1d824822bc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -173,6 +173,7 @@ derived_hash_with_manual_eq = "allow"
 manual_is_multiple_of = "allow"
 manual_range_contains = "allow"
 mutable_key_type = "allow"
+single_range_in_vec_init = "allow"
 wildcard_in_or_patterns = "allow"
 
 as_pointer_underscore = "warn"
diff --git a/Dockerfile b/Dockerfile
index c2be1c93397a..bd35bed8d8c9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.9.0 AS xx
-FROM --platform=$BUILDPLATFORM rust:1.95.0-alpine3.23 AS build
+FROM --platform=$BUILDPLATFORM rust:1.98.0-alpine3.23 AS build
 
 COPY --from=xx / /
 
diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs
index 68e3dcb0fe10..09215ab9a645 100644
--- a/crates/typst-layout/src/grid/layouter.rs
+++ b/crates/typst-layout/src/grid/layouter.rs
@@ -1620,11 +1620,10 @@ impl<'a> GridLayouter<'a> {
         if self
             .current
             .lrows
-            .last()
-            .is_some_and(|row| self.grid.is_gutter_track(row.index()))
+            .pop_if(|row| self.grid.is_gutter_track(row.index()))
+            .is_some()
         {
             // Remove the last row in the region if it is a gutter row.
-            self.current.lrows.pop().unwrap();
             self.current.repeated_header_rows =
                 self.current.repeated_header_rows.min(self.current.lrows.len());
         }
diff --git a/crates/typst-layout/src/math/scripts.rs b/crates/typst-layout/src/math/scripts.rs
index 997c2ad0c38a..d865a3fecd5e 100644
--- a/crates/typst-layout/src/math/scripts.rs
+++ b/crates/typst-layout/src/math/scripts.rs
@@ -1,5 +1,6 @@
 // Can be re-enabled once `Option::map_or_default` is stable in our MSRV.
 #![allow(unstable_name_collisions)]
+#![allow(clippy::incompatible_msrv)]
 
 // Is unused in compiler versions where `Option::map_or_default` is stable.
 #[allow(unused_imports)]
diff --git a/crates/typst-library/src/foundations/styles.rs b/crates/typst-library/src/foundations/styles.rs
index f8b8292773f4..5755dcae4c11 100644
--- a/crates/typst-library/src/foundations/styles.rs
+++ b/crates/typst-library/src/foundations/styles.rs
@@ -837,10 +837,7 @@ impl<'a> Iterator for Entries<'a> {
                 return Some(entry);
             }
 
-            match self.links.next() {
-                Some(next) => self.inner = next.iter(),
-                None => return None,
-            }
+            self.inner = self.links.next()?.iter();
         }
     }
 }
diff --git a/crates/typst-library/src/text/lang.rs b/crates/typst-library/src/text/lang.rs
index b2eb5c5ebfd4..0f4311b0b04c 100644
--- a/crates/typst-library/src/text/lang.rs
+++ b/crates/typst-library/src/text/lang.rs
@@ -706,7 +706,7 @@ mod tests {
 
     #[test]
     fn test_region_option_eq() {
-        let region = Some(Region([b'U', b'S']));
+        let region = Some(Region(*b"US"));
         assert!(option_eq(region, "US"));
         assert!(!option_eq(region, "AB"));
     }
diff --git a/crates/typst-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs
index 31c5776782a4..177d6b8f6fe8 100644
--- a/crates/typst-pdf/src/shape.rs
+++ b/crates/typst-pdf/src/shape.rs
@@ -44,9 +44,8 @@ pub(crate) fn handle_shape(
             None
         };
 
-        let stroke = shape.stroke.as_ref().and_then(|stroke| {
-            if stroke.thickness.to_f32() > 0.0 { Some(stroke) } else { None }
-        });
+        let stroke =
+            shape.stroke.as_ref().filter(|stroke| stroke.thickness.to_f32() > 0.0);
 
         let stroke = if let Some(stroke) = &stroke {
             let stroke = paint::convert_stroke(

From 6e53044dfcc6937ca975ccd44d05c7c2f7f881b7 Mon Sep 17 00:00:00 2001
From: Tom Kornack 
Date: Wed, 26 Aug 2026 16:01:17 +0000
Subject: [PATCH 076/105] Fix build on iOS by disabling support for custom PEM
 certificates (#8452)

---
 crates/typst-kit/src/downloader.rs | 16 ++++++++++++++--
 1 file changed, 14 insertions(+), 2 deletions(-)

diff --git a/crates/typst-kit/src/downloader.rs b/crates/typst-kit/src/downloader.rs
index c2ad07e5045a..cac8750f04d0 100644
--- a/crates/typst-kit/src/downloader.rs
+++ b/crates/typst-kit/src/downloader.rs
@@ -138,8 +138,20 @@ impl SystemDownloader {
 
         self.cert_path.as_ref().map(|path| {
             self.cert.get_or_try_init(|| {
-                let pem = std::fs::read(path)?;
-                Certificate::from_pem(&pem).map_err(io::Error::other)
+                #[cfg(all(target_vendor = "apple", not(target_os = "macos")))]
+                {
+                    let _ = path;
+                    return Err(io::Error::new(
+                        io::ErrorKind::Unsupported,
+                        "custom PEM certificates are not supported by native-tls on this platform",
+                    ));
+                }
+
+                #[cfg(not(all(target_vendor = "apple", not(target_os = "macos"))))]
+                {
+                    let pem = std::fs::read(path)?;
+                    Certificate::from_pem(&pem).map_err(io::Error::other)
+                }
             })
         })
     }

From e234bffe0341c16541425e59a69b2bf76d74793d Mon Sep 17 00:00:00 2001
From: Alex van de Sandt 
Date: Thu, 27 Aug 2026 15:20:00 +0000
Subject: [PATCH 077/105] Add `--no-fullscreen` flag to `typst watch` (#8677)

Co-authored-by: Bastien Voirin <16924802+bastienvoirin@users.noreply.github.com>
Co-authored-by: Ilia <43654815+istudyatuni@users.noreply.github.com>
Co-authored-by: Laurenz 
---
 crates/typst-cli/src/args.rs    |  7 +++++++
 crates/typst-cli/src/compile.rs |  5 +++++
 crates/typst-cli/src/watch.rs   | 32 +++++++++++++++++++++++++++-----
 3 files changed, 39 insertions(+), 5 deletions(-)

diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs
index bf7185d29fb2..a6968e3479c0 100644
--- a/crates/typst-cli/src/args.rs
+++ b/crates/typst-cli/src/args.rs
@@ -126,6 +126,13 @@ pub struct WatchCommand {
     #[clap(flatten)]
     pub args: CompileArgs,
 
+    /// Stops the watcher from taking over the terminal.
+    ///
+    /// With this flag, the watcher will only ever print normal output lines,
+    /// not clear or reconfigure the terminal.
+    #[arg(long)]
+    pub no_fullscreen: bool,
+
     /// Arguments for the HTTP server.
     #[cfg(feature = "http-server")]
     #[clap(flatten)]
diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs
index 71d9091added..be264dcbde4d 100644
--- a/crates/typst-cli/src/compile.rs
+++ b/crates/typst-cli/src/compile.rs
@@ -53,6 +53,8 @@ pub struct CompileConfig {
     pub warnings: Vec,
     /// Whether we are watching.
     pub watching: bool,
+    /// Whether to clear the terminal on recompilation (only while watching).
+    pub fullscreen: bool,
     /// Path to input Typst file or stdin.
     pub input: Input,
     /// Path to output file (PDF, PNG, SVG, or HTML).
@@ -195,6 +197,8 @@ impl CompileConfig {
             None
         };
 
+        let fullscreen = watch.map(|command| !command.no_fullscreen).unwrap_or(true);
+
         let mut deps = args.deps.clone();
         let mut deps_format = args.deps_format;
 
@@ -247,6 +251,7 @@ impl CompileConfig {
             deps_format,
             #[cfg(feature = "http-server")]
             server,
+            fullscreen,
         })
     }
 }
diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs
index 867b96d4e883..1c29e459dc71 100644
--- a/crates/typst-cli/src/watch.rs
+++ b/crates/typst-cli/src/watch.rs
@@ -11,6 +11,7 @@ use typst_kit::watcher::Watcher;
 
 use crate::args::{Input, Output, WatchCommand};
 use crate::compile::{CompileConfig, compile_once, print_diagnostics};
+use crate::terminal::TermOut;
 use crate::world::{SystemWorld, WorldCreationError};
 use crate::{print_error, terminal};
 
@@ -94,10 +95,26 @@ pub enum Status {
 impl Status {
     /// Clear the terminal and render the status message.
     pub fn print(&self, config: &CompileConfig) -> io::Result<()> {
-        let timestamp = chrono::offset::Local::now().format("%H:%M:%S");
-        let color = self.color();
-
         let mut out = terminal::out();
+
+        if config.fullscreen {
+            self.print_fullscreen(&mut out, config)?;
+        } else {
+            self.print_message(&mut out)?;
+            if !matches!(self, Status::Compiling) {
+                writeln!(out)?;
+            }
+        }
+
+        out.flush()
+    }
+
+    fn print_fullscreen(
+        &self,
+        out: &mut TermOut,
+        config: &CompileConfig,
+    ) -> io::Result<()> {
+        let color = self.color();
         out.clear_screen()?;
 
         out.set_color(&color)?;
@@ -122,10 +139,15 @@ impl Status {
         }
 
         writeln!(out)?;
-        writeln!(out, "[{timestamp}] {}", self.message())?;
+        self.print_message(out)?;
         writeln!(out)?;
 
-        out.flush()
+        Ok(())
+    }
+
+    fn print_message(&self, out: &mut TermOut) -> io::Result<()> {
+        let timestamp = chrono::offset::Local::now().format("%H:%M:%S");
+        writeln!(out, "[{timestamp}] {}", self.message())
     }
 
     fn message(&self) -> String {

From 321b4111068fb4d5f683641d54ff24b5a528e2cc Mon Sep 17 00:00:00 2001
From: TiggeZaki <82169475+TiggeZaki@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:46:22 +0000
Subject: [PATCH 078/105] Add more CJK brackets to punctuation width adjustment
 (#8556)

---
 crates/typst-layout/src/inline/shaping.rs | 46 +++++++++++++++++++----
 1 file changed, 39 insertions(+), 7 deletions(-)

diff --git a/crates/typst-layout/src/inline/shaping.rs b/crates/typst-layout/src/inline/shaping.rs
index 8d639c22fb48..fc323d1d37b5 100644
--- a/crates/typst-layout/src/inline/shaping.rs
+++ b/crates/typst-layout/src/inline/shaping.rs
@@ -1383,11 +1383,13 @@ fn assert_glyph_ranges_in_order(glyphs: &[ShapedGlyph], dir: Dir) {
 }
 
 // The CJK punctuation that can appear at the beginning or end of a line.
-pub const BEGIN_PUNCT_PAT: &[char] =
-    &['“', '‘', '《', '〈', '(', '『', '「', '【', '〖', '〔', '[', '{'];
+pub const BEGIN_PUNCT_PAT: &[char] = &[
+    '“', '‘', '《', '〈', '(', '『', '「', '【', '〖', '〔', '[', '{', '⦅', '〘',
+    '〝',
+];
 pub const END_PUNCT_PAT: &[char] = &[
     '”', '’', ',', '.', '。', '、', ':', ';', '》', '〉', ')', '』', '」', '】',
-    '〗', '〕', ']', '}', '?', '!',
+    '〗', '〕', ']', '}', '⦆', '〙', '〟', '?', '!',
 ];
 
 #[derive(Debug, Copy, Clone, Eq, PartialEq)]
@@ -1455,8 +1457,23 @@ fn is_cjk_left_aligned_punctuation(
         return true;
     }
 
-    // See appendix A.3 https://www.w3.org/TR/clreq/#tables_of_chinese_punctuation_marks
-    matches!(c, '》' | ')' | '』' | '」' | '】' | '〗' | '〕' | '〉' | ']' | '}')
+    // See CLReq appendix A.3 https://www.w3.org/TR/clreq/#table_of_bracket_indication_punctuation_marks
+    // and JLReq appendix A.2 https://www.w3.org/TR/jlreq/?lang=ja#cl-02
+    matches!(
+        c,
+        '》' | ')'
+            | '』'
+            | '」'
+            | '】'
+            | '〗'
+            | '〕'
+            | '〉'
+            | ']'
+            | '}'
+            | '⦆'
+            | '〙'
+            | '〟'
+    )
 }
 
 /// See 
@@ -1470,8 +1487,23 @@ fn is_cjk_right_aligned_punctuation(
     if matches!(c, '“' | '‘') && x_advance + stretchability.0 == Em::one() {
         return true;
     }
-    // See appendix A.3 https://www.w3.org/TR/clreq/#tables_of_chinese_punctuation_marks
-    matches!(c, '《' | '(' | '『' | '「' | '【' | '〖' | '〔' | '〈' | '[' | '{')
+    // See CLReq appendix A.3 https://www.w3.org/TR/clreq/#table_of_bracket_indication_punctuation_marks
+    // and JLReq appendix A.1 https://www.w3.org/TR/jlreq/?lang=ja#cl-01
+    matches!(
+        c,
+        '《' | '('
+            | '『'
+            | '「'
+            | '【'
+            | '〖'
+            | '〔'
+            | '〈'
+            | '['
+            | '{'
+            | '⦅'
+            | '〘'
+            | '〝'
+    )
 }
 
 /// See 

From 04720133f0bed8716145da7daa2ca29458f6b8ad Mon Sep 17 00:00:00 2001
From: Malo <57839069+MDLC01@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:58:04 +0000
Subject: [PATCH 079/105] Improve documentation for `direction` (#8791)

---
 crates/typst-library/src/layout/dir.rs | 33 +++++++++++++++++++++-----
 1 file changed, 27 insertions(+), 6 deletions(-)

diff --git a/crates/typst-library/src/layout/dir.rs b/crates/typst-library/src/layout/dir.rs
index 669c2fbc55ed..89bf770ad821 100644
--- a/crates/typst-library/src/layout/dir.rs
+++ b/crates/typst-library/src/layout/dir.rs
@@ -52,6 +52,8 @@ impl Dir {
 
     /// Returns a direction from a starting point.
     ///
+    /// This function does the opposite of @direction.start.
+    ///
     /// ```example
     /// #direction.from(left) \
     /// #direction.from(right) \
@@ -59,7 +61,12 @@ impl Dir {
     /// #direction.from(bottom)
     /// ```
     #[func(since = "0.14.0")]
-    pub const fn from(side: Side) -> Dir {
+    pub const fn from(
+        /// The starting point for the direction.
+        ///
+        /// This cannot be `{start}` or `{end}`.
+        side: Side,
+    ) -> Dir {
         match side {
             Side::Left => Self::LTR,
             Side::Right => Self::RTL,
@@ -70,6 +77,8 @@ impl Dir {
 
     /// Returns a direction from an end point.
     ///
+    /// This function does the opposite of @direction.end.
+    ///
     /// ```example
     /// #direction.to(left) \
     /// #direction.to(right) \
@@ -77,7 +86,12 @@ impl Dir {
     /// #direction.to(bottom)
     /// ```
     #[func(since = "0.14.0")]
-    pub const fn to(side: Side) -> Dir {
+    pub const fn to(
+        /// The end point for the direction.
+        ///
+        /// This cannot be `{start}` or `{end}`.
+        side: Side,
+    ) -> Dir {
         match side {
             Side::Right => Self::LTR,
             Side::Left => Self::RTL,
@@ -103,6 +117,9 @@ impl Dir {
 
     /// The corresponding sign, for use in calculations.
     ///
+    /// This is the sign of the vector going this direction along the
+    /// corresponding axis.
+    ///
     /// ```example
     /// #ltr.sign() \
     /// #rtl.sign() \
@@ -117,7 +134,9 @@ impl Dir {
         }
     }
 
-    /// The start point of this direction, as an alignment.
+    /// The starting point of this direction.
+    ///
+    /// This function does the opposite of @direction.from.
     ///
     /// ```example
     /// #ltr.start() \
@@ -135,7 +154,9 @@ impl Dir {
         }
     }
 
-    /// The end point of this direction, as an alignment.
+    /// The end point of this direction.
+    ///
+    /// This function does the opposite of @direction.to.
     ///
     /// ```example
     /// #ltr.end() \
@@ -153,7 +174,7 @@ impl Dir {
         }
     }
 
-    /// The inverse direction.
+    /// The inverse (opposite) direction.
     ///
     /// ```example
     /// #ltr.inv() \
@@ -161,7 +182,7 @@ impl Dir {
     /// #ttb.inv() \
     /// #btt.inv()
     /// ```
-    #[func(title = "Inverse", since = "forever")]
+    #[func(title = "Inverse", since = "forever", keywords = ["opposite"])]
     pub const fn inv(self) -> Dir {
         match self {
             Self::LTR => Self::RTL,

From ce2cee5e39246dc70beac6e0ad58d9578c1c9233 Mon Sep 17 00:00:00 2001
From: Laurenz 
Date: Thu, 27 Aug 2026 16:58:23 +0000
Subject: [PATCH 080/105] Fix vertical expansion of lone block in presence of
 tags (#8789)

---
 crates/typst-layout/src/flow/collect.rs       |  70 ++++++++++--------
 tests/ref/pdf/hashes.txt                      |   2 +
 tests/ref/render/issue-6822-layout-expand.png | Bin 0 -> 267 bytes
 .../ref/render/issue-6822-layout-multiple.png | Bin 0 -> 227 bytes
 tests/ref/svg/hashes.txt                      |   2 +
 tests/suite/layout/layout.typ                 |  16 ++++
 6 files changed, 59 insertions(+), 31 deletions(-)
 create mode 100644 tests/ref/render/issue-6822-layout-expand.png
 create mode 100644 tests/ref/render/issue-6822-layout-multiple.png

diff --git a/crates/typst-layout/src/flow/collect.rs b/crates/typst-layout/src/flow/collect.rs
index 089025b8fb9b..80d09a34fce1 100644
--- a/crates/typst-layout/src/flow/collect.rs
+++ b/crates/typst-layout/src/flow/collect.rs
@@ -41,21 +41,19 @@ pub fn collect<'a>(
     Collector {
         engine,
         bump,
-        children,
         locator: locator.split(),
         base,
         expand,
         output: Vec::with_capacity(children.len()),
         par_situation: ParSituation::First,
     }
-    .run(mode)
+    .run(children, mode)
 }
 
 /// State for collection.
 struct Collector<'a, 'x, 'y> {
     engine: &'x mut Engine<'y>,
     bump: &'a Bump,
-    children: &'x [Pair<'a>],
     base: Size,
     expand: bool,
     locator: SplitLocator<'a>,
@@ -65,16 +63,36 @@ struct Collector<'a, 'x, 'y> {
 
 impl<'a> Collector<'a, '_, '_> {
     /// Perform the collection.
-    fn run(self, mode: FlowMode) -> SourceResult>> {
+    fn run(
+        mut self,
+        children: &[Pair<'a>],
+        mode: FlowMode,
+    ) -> SourceResult>> {
+        // Extract leading and trailing tags.
+        let (start, end) = children.split_prefix_suffix(|(c, _)| c.is::());
+        let inner = &children[start..end];
+
+        for (c, _) in &children[..start] {
+            let elem = c.to_packed::().unwrap();
+            self.output.push(Child::Tag(&elem.tag));
+        }
+
         match mode {
-            FlowMode::Root | FlowMode::Block => self.run_block(),
-            FlowMode::Inline => self.run_inline(),
+            FlowMode::Root | FlowMode::Block => self.run_block(inner)?,
+            FlowMode::Inline => self.run_inline(inner)?,
         }
+
+        for (c, _) in &children[end..] {
+            let elem = c.to_packed::().unwrap();
+            self.output.push(Child::Tag(&elem.tag));
+        }
+
+        Ok(self.output)
     }
 
     /// Perform collection for block-level children.
-    fn run_block(mut self) -> SourceResult>> {
-        for &(child, styles) in self.children {
+    fn run_block(&mut self, children: &[Pair<'a>]) -> SourceResult<()> {
+        for &(child, styles) in children {
             if let Some(elem) = child.to_packed::() {
                 self.output.push(Child::Tag(&elem.tag));
             } else if let Some(elem) = child.to_packed::() {
@@ -82,7 +100,8 @@ impl<'a> Collector<'a, '_, '_> {
             } else if let Some(elem) = child.to_packed::() {
                 self.par(elem, styles)?;
             } else if let Some(elem) = child.to_packed::() {
-                self.block(elem, styles);
+                let alone = children.len() == 1;
+                self.block(elem, styles, alone);
             } else if let Some(elem) = child.to_packed::() {
                 self.place(elem, styles)?;
             } else if child.is::() {
@@ -103,23 +122,18 @@ impl<'a> Collector<'a, '_, '_> {
                 ));
             }
         }
-
-        Ok(self.output)
+        Ok(())
     }
 
     /// Perform collection for inline-level children.
-    fn run_inline(mut self) -> SourceResult>> {
-        // Extract leading and trailing tags.
-        let (start, end) = self.children.split_prefix_suffix(|(c, _)| c.is::());
-        let inner = &self.children[start..end];
-
+    fn run_inline(&mut self, children: &[Pair<'a>]) -> SourceResult<()> {
         // Compute the shared styles.
-        let styles = StyleChain::trunk_from_pairs(inner).unwrap_or_default();
+        let styles = StyleChain::trunk_from_pairs(children).unwrap_or_default();
 
         // Layout the lines.
         let lines = crate::inline::layout_inline(
             self.engine,
-            inner,
+            children,
             &mut self.locator,
             styles,
             self.base,
@@ -127,20 +141,10 @@ impl<'a> Collector<'a, '_, '_> {
         )?
         .into_frames();
 
-        for (c, _) in &self.children[..start] {
-            let elem = c.to_packed::().unwrap();
-            self.output.push(Child::Tag(&elem.tag));
-        }
-
         let leading = styles.resolve(ParElem::leading);
         self.lines(lines, leading, styles);
 
-        for (c, _) in &self.children[end..] {
-            let elem = c.to_packed::().unwrap();
-            self.output.push(Child::Tag(&elem.tag));
-        }
-
-        Ok(self.output)
+        Ok(())
     }
 
     /// Collect vertical spacing into a relative or fractional child.
@@ -231,10 +235,14 @@ impl<'a> Collector<'a, '_, '_> {
 
     /// Collect a block into a [`SingleChild`] or [`MultiChild`] depending on
     /// whether it is breakable.
-    fn block(&mut self, elem: &'a Packed, styles: StyleChain<'a>) {
+    fn block(
+        &mut self,
+        elem: &'a Packed,
+        styles: StyleChain<'a>,
+        alone: bool,
+    ) {
         let locator = self.locator.next(&elem.span());
         let align = styles.resolve(AlignElem::alignment);
-        let alone = self.children.len() == 1;
         let sticky = elem.sticky.get(styles);
         let breakable = elem.breakable.get(styles);
         let fr = match elem.height.get(styles) {
diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt
index 9019986bf1cf..8ab78caada4a 100644
--- a/tests/ref/pdf/hashes.txt
+++ b/tests/ref/pdf/hashes.txt
@@ -833,6 +833,8 @@ cb0c6862c965722f7e8a4af6ed94215e issue-6597-gradient-angle-negative-size
 ff9ffb70bb4aad13c86fe697e97649d1 issue-6666-auto-hlines-around-footer
 351cf002c18462543af122efcc408045 issue-6666-auto-hlines-around-header
 512672b1f092bfb971ccdf09c04a9f8e issue-6680-gradient-linear-with-aspect-correction
+3431cebc19e3975ce34516c342abda3c issue-6822-layout-expand
+71e8903c0a518bbf5dc76efd6f2b4839 issue-6822-layout-multiple
 0193206bf0e046bb87528d6a6df9d60a issue-6869-image-zero-sized
 235e60046edf64f088ea9a8483e9ffaa issue-6961-tab-crlf-raw-indent
 e6c3701e2842f398fcb0758a4f9533ab issue-7103-wrong-state-calculation
diff --git a/tests/ref/render/issue-6822-layout-expand.png b/tests/ref/render/issue-6822-layout-expand.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a38ff0c1237fdd38b1de9db4b6f5d0bd81ea3dd
GIT binary patch
literal 267
zcmV+m0rdWfP)^E
z%J;v(j~BvQUSvTpexd6z~xP|K~N-#7)A1U{eRd%ifj?
zgfL;kgo}pno8$(8r|(M}^wg=sz|X-C0)NKe2L$dXixC7q?RQr$5W<8BR~ObGc$UTn
z0cU5^(2$8=tqJOL#Rm{PTqEGDv+8Qh#2GhNDPWiI7Nsa2jXjv*Dd-rh9iI^-bYdQm6$
zTf)K{Q8%Or9e*+(
z{3){VdXW8fUPs=Z`iuvEa)f?VZraTf=aU}I#v%a$JJdIDpDZ@KQN#YRm644tZ=uvX
WlgevVDZ3a!uJ?5Hb6Mw<&;$UXVO6UD

literal 0
HcmV?d00001

diff --git a/tests/ref/svg/hashes.txt b/tests/ref/svg/hashes.txt
index 9fba0712037e..12220559ce2f 100644
--- a/tests/ref/svg/hashes.txt
+++ b/tests/ref/svg/hashes.txt
@@ -831,6 +831,8 @@ c783423dcbc67df08ddb0cec95b809c0 issue-6399-grid-cell-rowspan-set-rule
 eddda896523fdf36484e5c16fa9c2271 issue-6666-auto-hlines-around-footer
 eaaca053bc6feddd3f1b9b585d92246c issue-6666-auto-hlines-around-header
 061808148873e4f5c8cea3c5b40e05af issue-6680-gradient-linear-with-aspect-correction
+77a6fe5b06d7cffd9c2039b733e05c0a issue-6822-layout-expand
+603d7dd16a5926119146683428ff5233 issue-6822-layout-multiple
 f1003f00895ba8e491665370131ce322 issue-6869-image-zero-sized
 c1e125e744728bcf7eb4d563a4350ed3 issue-6961-tab-crlf-raw-indent
 3d2d1e25d75788136e8b71bcb22d1a8f issue-7103-wrong-state-calculation
diff --git a/tests/suite/layout/layout.typ b/tests/suite/layout/layout.typ
index 5e66b5dc0fdf..c3335650690c 100644
--- a/tests/suite/layout/layout.typ
+++ b/tests/suite/layout/layout.typ
@@ -12,3 +12,19 @@
   h(1em)
   place(left, rect(width: 80pt, stroke: blue))
 })
+
+--- issue-6822-layout-expand paged ---
+// Ensure that the body of `layout` can expand and respects alignment.
+#rect(width: 100%, height: 50pt, layout(_ => {
+  place(top + left)[1]
+  place(center + horizon)[2]
+  place(bottom + right)[3]
+}))
+
+--- issue-6822-layout-multiple paged ---
+// But if there are multiple children (the `layout` is not the lone child), it
+// cannot expand vertically anymore, just like `pad`.
+#rect(width: 100%, height: 50pt, {
+  pad(y: 1pt, align(center + horizon)[1])
+  layout(_ =>  place(center + horizon)[2])
+})

From 6a6f1a97a3446fc6ba5a723b0baf20a0cfeaaef5 Mon Sep 17 00:00:00 2001
From: Philipp Niedermayer 
Date: Fri, 28 Aug 2026 13:21:31 +0000
Subject: [PATCH 081/105] Add column separator (#8353)

Co-authored-by: Laurenz 
---
 crates/typst-layout/src/flow/compose.rs       |  74 ++++++++++++++++--
 crates/typst-layout/src/flow/mod.rs           |  17 +++-
 crates/typst-layout/src/pages/run.rs          |   1 +
 crates/typst-library/src/layout/columns.rs    |  54 +++++++++++--
 tests/ref/pdf/hashes.txt                      |  31 ++++----
 tests/ref/render/columns-balanced-blocks.png  | Bin 145 -> 184 bytes
 tests/ref/render/columns-balanced-figures.png | Bin 15652 -> 15758 bytes
 .../columns-balanced-footnote-overflow.png    | Bin 18289 -> 18398 bytes
 .../ref/render/columns-balanced-footnotes.png | Bin 19689 -> 19868 bytes
 .../render/columns-balanced-leftover-2-3.png  | Bin 13956 -> 14173 bytes
 .../render/columns-balanced-leftover-4.png    | Bin 10454 -> 10738 bytes
 .../render/columns-balanced-leftover-5.png    | Bin 11596 -> 12058 bytes
 .../render/columns-balanced-leftover-6.png    | Bin 14166 -> 14835 bytes
 .../render/columns-balanced-leftover-7.png    | Bin 15661 -> 16565 bytes
 tests/ref/render/columns-balanced-lists.png   | Bin 4565 -> 4683 bytes
 .../ref/render/columns-balanced-pagebreak.png | Bin 6964 -> 7060 bytes
 tests/ref/render/columns-balanced-tables.png  | Bin 1660 -> 1675 bytes
 tests/ref/render/columns-balanced-widow.png   | Bin 6193 -> 6373 bytes
 tests/ref/render/columns-balanced.png         | Bin 10210 -> 10325 bytes
 .../ref/render/columns-separator-content.png  | Bin 0 -> 7549 bytes
 tests/ref/render/columns-separator-rtl.png    | Bin 0 -> 233 bytes
 tests/ref/render/columns-separator.png        | Bin 0 -> 5177 bytes
 tests/ref/svg/hashes.txt                      |  31 ++++----
 tests/suite/layout/columns.typ                |  59 ++++++++++----
 24 files changed, 207 insertions(+), 60 deletions(-)
 create mode 100644 tests/ref/render/columns-separator-content.png
 create mode 100644 tests/ref/render/columns-separator-rtl.png
 create mode 100644 tests/ref/render/columns-separator.png

diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs
index ab3ab284a252..f590d39e1608 100644
--- a/crates/typst-layout/src/flow/compose.rs
+++ b/crates/typst-layout/src/flow/compose.rs
@@ -110,7 +110,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> {
     fn page_contents(&mut self, locator: Locator, regions: Regions) -> FlowResult {
         // No point in create column regions, if there's just one!
         if self.config.columns.count == 1 {
-            return self.column(locator, regions).map(|(frame, _)| frame);
+            return self.column(locator, regions).map(|(frame, ..)| frame);
         }
 
         // Create a backlog for multi-column layout.
@@ -139,24 +139,55 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> {
         let mut offset = Abs::zero();
         let mut locator = locator.split();
         let mut total_used_height = Abs::zero();
+        let mut last_column_separator_height = None;
 
         // Lay out the columns and stitch them together.
         for i in 0..self.config.columns.count {
             self.column = i;
-            let (frame, used_height) = self.column(locator.next(&()), inner)?;
+            let (frame, used_height, column_separator_height) =
+                self.column(locator.next(&()), inner)?;
             total_used_height += used_height;
 
             if !regions.expand.y {
                 output.size_mut().y.set_max(frame.height());
             }
 
+            // Column separator between this and the previous column.
+            if let Some(separator) = &self.config.columns.separator
+                && let Some(last_separator_height) = last_column_separator_height
+                && !column_separator_height.min(last_separator_height).is_zero()
+            {
+                let mid = offset + 0.5 * self.config.columns.gutter;
+                let x = if self.config.columns.dir == Dir::LTR {
+                    mid
+                } else {
+                    regions.size.x - mid
+                };
+                let height = column_separator_height.max(last_separator_height);
+                let frame = layout_column_separator(
+                    self.engine,
+                    separator,
+                    self.config,
+                    Size::new(self.config.columns.gutter, height),
+                )?;
+                output.prepend_frame(
+                    // Prepend so it's behind both adjacent columns.
+                    Point::with_x(x - 0.5 * frame.width()),
+                    frame,
+                );
+            }
+            last_column_separator_height = Some(column_separator_height);
+            if i > 0 {
+                offset += self.config.columns.gutter;
+            }
+
             let width = frame.width();
             let x = if self.config.columns.dir == Dir::LTR {
                 offset
             } else {
                 regions.size.x - offset - width
             };
-            offset += width + self.config.columns.gutter;
+            offset += width;
 
             // During distribution, the baseline of the region is set to the
             // baseline of the first frame - e.g., the first paragraph line.
@@ -186,8 +217,14 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> {
     ///
     /// Returns a `FlowResult` containing a tuple of
     /// - `0`: The laid out frame.
-    /// - `1`: The height actually used by the inner contents (used for column balancing logic).
-    fn column(&mut self, locator: Locator, regions: Regions) -> FlowResult<(Frame, Abs)> {
+    /// - `1`: The height actually used by the inner contents (used for column
+    ///   balancing logic).
+    /// - `2`: The height the column separator (if any) should have.
+    fn column(
+        &mut self,
+        locator: Locator,
+        regions: Regions,
+    ) -> FlowResult<(Frame, Abs, Abs)> {
         // Reset column insertion when starting a new column.
         self.column_insertions = Insertions::default();
 
@@ -219,11 +256,18 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> {
                 Err(Stop::Relayout(PlacementScope::Column)) => {
                     *self.work = checkpoint.clone();
                 }
-                err => return err,
+                Err(err) => return Err(err),
             }
         };
         drop(checkpoint);
 
+        let column_separator_height = if self.column_insertions.bottom_floats.is_empty() {
+            used_height
+        } else {
+            self.column_balancing_height
+                .unwrap_or(inner.height() + self.column_insertions.float_height())
+        };
+
         self.work.footnotes.extend(self.footnote_queue.drain(..));
         if let Some(spill) = self.footnote_spill.take() {
             self.work.footnote_spill = Some(spill);
@@ -249,7 +293,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> {
             )?;
         }
 
-        Ok((output, used_height))
+        Ok((output, used_height, column_separator_height))
     }
 
     /// Lay out the inner contents of a column.
@@ -660,6 +704,22 @@ fn layout_footnote(
     })
 }
 
+/// Lay out the column separator, typically a line.
+fn layout_column_separator(
+    engine: &mut Engine,
+    separator: &Content,
+    config: &Config,
+    base: Size,
+) -> SourceResult {
+    crate::layout_frame(
+        engine,
+        separator,
+        Locator::root(),
+        config.shared,
+        Region::new(base, Axes::splat(true)),
+    )
+}
+
 /// An additive list of insertions.
 #[derive(Default)]
 struct Insertions<'a, 'b> {
diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs
index 12f25f68d97a..b5c4b571752d 100644
--- a/crates/typst-layout/src/flow/mod.rs
+++ b/crates/typst-layout/src/flow/mod.rs
@@ -21,13 +21,14 @@ use typst_library::introspection::{
     Introspector, Location, Locator, LocatorLink, SplitLocator, Tag,
 };
 use typst_library::layout::{
-    Abs, ColumnsElem, Dir, Em, Fragment, Frame, PageElem, PlacementScope, Region,
-    Regions, Rel, Size,
+    Abs, Angle, ColumnsElem, Dir, Em, Fragment, Frame, HAlignment, PageElem,
+    PlacementScope, Region, Regions, Rel, Size, VAlignment,
 };
 use typst_library::model::{FootnoteElem, FootnoteEntry, LineNumberingScope, ParLine};
 use typst_library::pdf::ArtifactKind;
 use typst_library::routines::{Arenas, FragmentKind, Pair, RealizationKind};
 use typst_library::text::TextElem;
+use typst_library::visualize::LineElem;
 use typst_library::{Library, World};
 use typst_utils::{LazyHash, NonZeroExt, Numeric, Protected};
 
@@ -75,6 +76,7 @@ pub fn layout_fragment(
             count: NonZeroUsize::ONE,
             balanced: false,
             gutter: Rel::zero(),
+            separator: None,
         },
     )
 }
@@ -106,6 +108,7 @@ pub fn layout_columns(
             count: elem.count.get(styles),
             balanced: elem.balanced.get(styles),
             gutter: elem.gutter.resolve(styles),
+            separator: elem.separator.get_cloned(styles),
         },
     )
 }
@@ -261,6 +264,12 @@ fn configuration<'x>(
                 gutter,
                 dir,
                 balanced: column.balanced,
+                separator: column.separator.map(|separator| {
+                    separator
+                        .set(LineElem::length, Rel::one())
+                        .set(LineElem::angle, Angle::deg(90.0))
+                        .aligned(HAlignment::Center + VAlignment::Horizon)
+                }),
             }
         },
         footnote: FootnoteConfig {
@@ -368,6 +377,8 @@ pub struct ColumnOptions {
     pub balanced: bool,
     /// The spacing between columns.
     pub gutter: Rel,
+    /// The separator between columns.
+    pub separator: Option,
 }
 
 /// Shared configuration for the whole flow.
@@ -411,6 +422,8 @@ struct ColumnConfig {
     dir: Dir,
     /// Whether to equalize the height of columns by breaking columns early.
     balanced: bool,
+    /// The separator between columns.
+    separator: Option,
 }
 
 /// Configuration of line numbers.
diff --git a/crates/typst-layout/src/pages/run.rs b/crates/typst-layout/src/pages/run.rs
index 0616ef6f1292..3e95090a133e 100644
--- a/crates/typst-layout/src/pages/run.rs
+++ b/crates/typst-layout/src/pages/run.rs
@@ -197,6 +197,7 @@ fn layout_page_run_impl(
             count: styles.get(PageElem::columns),
             balanced: styles.get(ColumnsElem::balanced),
             gutter: styles.get(ColumnsElem::gutter).resolve(styles),
+            separator: styles.get_cloned(ColumnsElem::separator),
         },
         FlowMode::Root,
     )?;
diff --git a/crates/typst-library/src/layout/columns.rs b/crates/typst-library/src/layout/columns.rs
index 65978e0347ec..47c271acf556 100644
--- a/crates/typst-library/src/layout/columns.rs
+++ b/crates/typst-library/src/layout/columns.rs
@@ -64,31 +64,73 @@ pub struct ColumnsElem {
 
     /// The size of the gutter space between each column.
     ///
-    /// #example(
-    /// ```
+    /// ```example
     /// #set page(columns: 2, height: 5cm)
     /// #set par(justify: true)
     /// #set columns(gutter: 15mm)
     /// #lorem(30)
     /// ```
-    /// )
     #[default(Ratio::new(0.04).into())]
     pub gutter: Rel,
 
     /// Whether to equalize the height of columns by breaking columns early.
     ///
-    /// #example(
-    /// ```
+    /// ```example
     /// #set page(columns: 2, height: 5cm)
     /// #set par(justify: true)
     /// >>> #set columns(gutter: 15pt)
     /// #set columns(balanced: true)
     /// #lorem(30)
     /// ```
-    /// )
     #[default(false)]
     pub balanced: bool,
 
+    /// The separator to place between each pair of non-empty columns.
+    ///
+    /// By default, the content is centered and @line[lines] use a length of
+    /// `{100%}` and an angle of `{90deg}` unless specified otherwise.
+    ///
+    /// ```example
+    /// #set page(columns: 2, height: 5cm)
+    /// #set par(justify: true)
+    /// #set columns(
+    ///   gutter: 2em,
+    ///   separator: line(),
+    /// )
+    /// #lorem(35)
+    /// ```
+    ///
+    /// #example(
+    ///   title: "Separator with extended, dotted line",
+    ///   ```
+    ///   #set page(columns: 2, height: 5cm)
+    ///   #set par(justify: true)
+    ///   #set columns(
+    ///     gutter: 2em,
+    ///     separator: line(
+    ///       stroke: (dash: "dotted"),
+    ///       length: 100% + 2 * 4pt,
+    ///     ),
+    ///   )
+    ///   #lorem(35)
+    ///   ```
+    /// )
+    ///
+    /// #example(
+    ///   title: "Custom separator ornament",
+    ///   ```
+    ///   #set page(columns: 3, width: 15cm, height: 3cm)
+    ///   #set par(justify: true)
+    ///   #set columns(
+    ///     gutter: 30pt,
+    ///     separator: curve(curve.cubic((-8pt, 0%), (8pt, 50%), (0pt, 50%))
+    ///   ))
+    ///   #lorem(36)
+    ///   ```
+    /// )
+    #[default]
+    pub separator: Option,
+
     /// The content that should be layouted into the columns.
     #[required]
     pub body: Content,
diff --git a/tests/ref/pdf/hashes.txt b/tests/ref/pdf/hashes.txt
index 8ab78caada4a..f1f7442d2fd0 100644
--- a/tests/ref/pdf/hashes.txt
+++ b/tests/ref/pdf/hashes.txt
@@ -101,20 +101,20 @@ bc59dcdfa945150151f451bd83d4b299 color-outside-srgb-gamut
 82c48571ad9de5290d6ec611b983c970 color-rotate-hue
 e4378b03c0c88a04b7bc5b0c3d9e23f9 color-saturation
 72bf371d532444341689298ed44c4353 color-spaces
-49417c77b77d95ffc4155a083c6f2f18 columns-balanced
-85466b150e564a3d71f8179b676a0457 columns-balanced-blocks
-36c3820835643878a4550af0a04c566b columns-balanced-figures
-98524c510f636e12f4d2e26b4e8a10e0 columns-balanced-footnote-overflow
-afeb78c647d8b3c7f8b4b97631dbe817 columns-balanced-footnotes
-3083467593aaafa5c14be713f0902bfb columns-balanced-leftover-2-3
-0528be61fa39c9c839d4be18cbdf25ce columns-balanced-leftover-4
-a648ffc83e38dbd3c030920ac369aa53 columns-balanced-leftover-5
-12e1924ffc8dbcf173d80e5f5c23013f columns-balanced-leftover-6
-48b1c452ae962934a7befade7cfcf363 columns-balanced-leftover-7
-431f30b6cf72f01cb2aab1dd147b62f2 columns-balanced-lists
-ef9b28c336f647ab3ed1d5d2d9c6a4bb columns-balanced-pagebreak
-24afec7f8d633c777a492b3ab00f3899 columns-balanced-tables
-8b80a853b8ea9837da6a858b6613d2b6 columns-balanced-widow
+106ca3abeb419ef18f58aed92b6621d8 columns-balanced
+f2f3c080d6d051b7d0c8b77efa382ec6 columns-balanced-blocks
+2b70d0e38b701ed8c60eca68eb883486 columns-balanced-figures
+bda8cef0f423e48e110c305a2fe25e08 columns-balanced-footnote-overflow
+3cc2fa297d95b06689be2e83e31316b3 columns-balanced-footnotes
+ca532e75721cbaa42e1f821673d9086e columns-balanced-leftover-2-3
+940d0d040e004e0587817e438ab68a74 columns-balanced-leftover-4
+46d9b69d27669702d43edb3f3b36d4dc columns-balanced-leftover-5
+bd79220b2ab641187e5c311443ab03ff columns-balanced-leftover-6
+2977d2609ef5e0dde2ebc0e2667abd37 columns-balanced-leftover-7
+03ac29f29e6217f80640a9a3362ec38e columns-balanced-lists
+201352d61ed3f7b87b7010aea4650fa4 columns-balanced-pagebreak
+5a2317df5d041304d1a12ef68acc89f3 columns-balanced-tables
+cd6491bc7c108f3b3be2f70f25d61f47 columns-balanced-widow
 654fce76fc39360b2865c0c3f24f3f51 columns-colbreak-after-place
 73fd768e2c8e0f794893f42c43cd11ae columns-empty-second-column
 3e28bbd810096e7263a90a310d2180db columns-in-auto-sized-rect
@@ -124,6 +124,9 @@ ba490b13138bbf60faefb851ca5c8937 columns-one
 ed9f4f3b2a74c65980014e7d792af8a5 columns-page-height-auto
 63c82d8a3e66a99a9266440464c6ffef columns-page-width-auto
 13252b5a572a88d4968edd1d55d4ce60 columns-rtl
+6c92b22fec262799ce84e773bfd1be37 columns-separator
+dfc0892670ccbeadbdb63df77099dc00 columns-separator-content
+f7701358527f6952a1ab7e45d302eaf2 columns-separator-rtl
 2ff393d978236989585e55de03a42f64 columns-set-page
 f020e6f8ad269e224e5f89ea55c85d85 columns-set-page-colbreak-pagebreak
 0f30d5885f06659cc40196050421818b coma
diff --git a/tests/ref/render/columns-balanced-blocks.png b/tests/ref/render/columns-balanced-blocks.png
index 912cc9d9bb60ef7df3083c16c40a37250b3f9a3b..fc0868bf4262fd95c0c1419e38a7f79b48381867 100644
GIT binary patch
delta 168
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 I1Dl2ZUto~lVgLXD delta 15188 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 A6#xJL diff --git a/tests/ref/render/columns-balanced-footnote-overflow.png b/tests/ref/render/columns-balanced-footnote-overflow.png index 6b42743c8921e12489d20ea3926689d82c591db4..310c1a1a5e4064811c8529f62d1b0c83b8566a06 100644 GIT binary patch literal 18398 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 FV1oU;tG)mL literal 18289 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 diff --git a/tests/ref/render/columns-balanced-footnotes.png b/tests/ref/render/columns-balanced-footnotes.png index 535e7523dd437f724e7f490b4bda69d2857da718..2ad7bb11ac84d35a311b84f2f60a528e550ed6c4 100644 GIT binary patch literal 19868 zcmV);K!(4GP)pYz$fcklZ3>yv-} z$&)85R;)OE`gEfG^XJdcn>R21|B_O~{X0#ZIPu3Hf1EyjdfYpv!Gj0ixN#%y-P7^o z$9wngooJKg0|yRNpULNwAwz~un>HodRLuA9-+yb}&YU^Zty?z>`6ruSJ}`Rp=!+LG zzJf!aK7AfMc<>gYsokhiBa0I!PE46H#p2$*d(3%LK5%5ffB}g%m1XA4nUlIQV#J7@ zJ9j49eERfh-MV$--V-t5_U+s6OZq=_!tcNTKK|X)^XJdw|0{I%?Ac<)ioLwwvSrJq zOP3~}Px|!fH*VaRXtP$WT4D3;=<3z0`Sa(0g*WWKJ2$3Fmo6cbqD6~pk8jH4*s)`! zN|j2qxo+LMOqntzb)`dx4s++uO+ugdLp<<5)CtiipL~+=b-{uK{rmUt(WA$;Yu6-n z;lhPy&z@bcUOhjJ9656Q`0*Ti>C&a*#fuy8nl)<%RK|@PSEELaef##sB|(y}Y}v90 z4jgb&G4$)#4@1oTG-}i+NI87?@HK1JlrLXCpp;goZI&)wdf~!_ty;C}-o5+e$&(ep zwr$(uk|uDA56!KSzSp&DSF95CoCy;qXv;l&_S90S#1P{^yLRp3UPrLgdH&h6XHV_g zwU;beVx}rlq6BX&TC}KP!-lO}w~l6tD_5>)IQ*mgd8Na!VZ*v~=@QzhCSW*RzI-{q z(b+5S-MV$FQl(14Mj`0cs~1_Jljx9r`}Q?X6UT4!=g)7_q>0|nt2=h=a7Gyr-Jn4O z_TIU3#}2xD`SOq#x5?Yq1G!Wc&8*0kaDfD8QbF4hdYR|1(w^prM)uLLpYTRc*gv8piWs78e1-9kO zm(PN8g9%x)X7x05F)-?ca;tjv>Rv$!{z0fbW?(WqAvG-IB0#YySg@d~;D;~2{8E)l zFPmuP%9R%`To78(@|q`4p3tR(S37s^tN<*&`|dlYaeKy$8NEsZV+gJ$rUf03e#U(1%CedXOWT#4giZ}c0#sG<=}bVHrtzry@EW8NFVU0RMcLoRH;N^=FFKk zeG7P+a`WcRYXP!BXSA<80FZ@R0<}0-sZxa-m4{8Fij=GIt!yLZwIa&6Zpl1B^ z?YG~`kO1-aLzTiEDBzVVtP|OlBS((#ZA_etAv!WPBnE~w;l_#;D|)4vlZiOIst6^J z8gn`1^e?~sVo{_>5fvXwf??Kj%PS1_c@VjB!-fs@>(`G@nrO3o_ioosK|$7^fBtE~ zjFc%;%8ZDWhQ_3jk*{^5rom`Oc7Hj<^S;h9tsDsNEBGwQHVp+e!`EIcI~jC-ST zRO8aqqH$E&gnSLGB7MLImy%+3@T&Q!x8HyN-P(Sr4fxKw=nA&D1!PxCyg`M7@9a>L8v9^r+wM+3ekAOA|g2Z*I$1{uaGeqMNiEm zV(7mpLy(1{P#UipM;RbOVd26bfBZoSlBfuq?E@!}YypH;uU`GlH{UoJ_K}6do`Q$s zk|s>T`S8aE&2s0?t*(^1;fl+`WCEOy962KDGSYcGlPQ8?qO10_5ef9^r=NcO@yFH} zrbv-Oo5BsrY*~m8d;*g}F%*|P`skyNfnj$iVL7KZ7??I~ zS|BLIf*w4tJVIW<9a7-3tiYdix6sRGQh=(#`B?EP&a-CCQYyMDzXj2lXeWj>IY8Cu zx+k8I8%YeW2@fEd4>i1oB!N4k<`sSR*=KaneuMNczW5@{L3#j9ByDgq&M0E|nsF4$ zs(XWn{(u6w8kPnP8ibu*r!q~z_C>-zz~C~1nGI=p&2>`~0E4gG8vjq^n}m<>p(v2k z;(e{r5=4PXUk>6FfXdg1`G+QWHg4S5Cxls%2rvWxFiD8?FXJ&C4EjKbQ}mxsojNhZ zXM+}GY@>e|a!ALS0@&)epf{PF(B#7*yf3Y}g4Osd6?uS<;V?SMGsLllqnPiaAu#9$ z=FB;+hjGZ{W35ClUi=rkc94TW2txtX7;nvnYjGlvp)YAdT&ie>Onw)}j00^yzoiB5 z-VuA2z*ebOEIjo~j29}nMLYCp)O*NQ#=^uv3EnoSJOSFYf#V7}n>Nr`B3qk;?@*Gb z#u&apruXi74e`(>LnE$Zn}y(oCGe9J?0xevhDYxl%qRr7h9~+u1eN~EFI<8ZgB9&E ztO@NWm@O$#A?Yx(&L#kYY^^oq@dA)Vm&j2kd%{kQAE+s^poY)h1i}h8{jE=rPX>>J)PtTi#Lkde5X_fs*ADigK1|Eebag7|24GZ4{Z1 zI~cOpJt6ukavuB348p%kwZY3GBxcp*1VWSZ(Qt$l6$|=j5S~*cO@Vrz%X--b?VQjg z2#K0U4QV%6oJUG_L}4p%=20!>7m^^%KV~)!!r+h~{JGpNkx1lFNf1UN2g@}OtczPm z|1Q|X7)PT|_U>KPl50ETc%Tcq3v_9OkU)tfP#{Ihr9>zYC=)`79>|Yi96B@(jd5Vi z3H=PlnNQ?*@DKhlSgv)c>b2MIv-j?rG)?VVRdbFx?p5QPv%F1xnZ;4*!o%=Mu&zSl z&4&vSuea*1aSTzRgsS=+l1m6xhA!v<&QW@0U$tiN2)C$j zp7ay&03nrwg9H}_+HkD=?L3qqCUQI>%T?7CNgW$el~(z_V5{(|Bq}DTiA&E^|CMW* z5WH(x9GQb#{O@+ZqElJc#c~x$CAIHh->Z$rE9Sxg3^3h|hQ0t^*Kh!}@Emx{gQ62i z00O~J$isp7sWi;}DWfwR#48`WD_AzrM*LS7HU*5$AL9>v=H)o4rjMC1P>1kIkOok= zpj9gAdPA6sKXJPfFc8dzWa&W5olH>i13e+CaS!ES0IR%Ops`i_BOc^c-~wYbt{S>v zMbMNGt`I+6DZ4h1EBz_+hnJ*g2p(9rKW7LmB@sMS(@&q3!LUW$e36%8H3ef8dT>2m z(lfQE&#f;*1`(+EW4OqXbSO4WUObEt7QaJRU0Gg(B;69Uz{n6xiV%S-Scl23L_z~l z3yRAO6!-)6fvxED*n$be|G=d#4y)=G`4xPrKgot5rRGow=Jv%GUto}B0RRf;{KTJs z{zwrA9To&@G;1n2!MVd4FAi{RmsL9(jJ&;*MJ?< zHfV-$KiG#a;fAS*1As3y(~Q+f7+jl}fjn~jzx?tGKY}WF!gD2ZrVbi{AxeUXoN?jN zT7nE+RaaM=(|ZQL=O8a8e$q!$x7oQ3!GAe-NMQuD|~JE1y*)j8~317XTpo zmb`#nSbq4`(1HOz!CcIg?)~=LZrTQ(Ie^ol?)*2KQI0PQy=;Uupo6Y-y93@xCjvcTBw+p~U={!irJ4&W}6{Px>#CmjtTJ;oVW{ezT?}NB^w!pV&pK`jhA4Z3q0l5x^~ybgU~P8{%{2ag5e7m~fHcrcPq!;g#Z*+vB8J3@5t= z`agqgBJjGRv;*0S2$>N=N3-*k!Ei!Our1uC&n*=auc#j#9=v!X!jY22IV03L+9V^4 zIl}0Mr_cdH8pv=f>8QcPXnsPjGN@CCXa3&Ri&rfC1dHdM!}~ITmPl)50vbCJpm`dL zbo5YHIcz;FMz<g5p#86_%&JVD*>&?PTF%Tu7m^`=MKKtym7Atal))t+-i`#p60-bjpew`0PqDu7{ItCXNiZBLkf|l!nO08gUXl z95YSab3N6Jx5dWoZ|um0-FC0oMP!Jl>&`FOUY{-yZy8=dd;~alzCq)pYlf3G*Kh%d zCjxhk7V&OWv3b4!{`+S1@y8$g6>Pn^$JkW2%iewWUA*muGu_awfX0DJ&{}TRh(r7w z3~x;{W=8N_u#%1nEb?!fO&HSsmfmu@Qg&v2{5;CLxSo@fKngXkVfPY9c`0Wp2AYhZ zFDuY<>8|rQA`NrA=#bMgkl;WC{i&-GddGYTVq^}6<8S0b;E_D5e6qzX0vhfZ4ub>= z(VJ4{rA!CYrR>g|6h`Yux>ck_JkILYbOc6$NVi)eVJ_cv&?dZafdnIHdnUX97Bf0G z$+>!-VJ%3)ALMy{aNdK2ltv_%AL`agK}k}82}Z|Zc?7o521@C%RzGHk+j{OvN#Z&W z2#c0NV53oth()NC-qSF%z;@oj^!Y)VJpQm2kuWrKD?IU*C_SDTs}zYWfa5p5n4u4( zMparSYQ8N^)`MBY?iUI97os$%;R>5wi5_yNA(Usq$r*xV$$|((2JUWZ;WY12z4&^J zoqOk|=yGz?9dGt-HOkduRnbvB+707j8DStVrP}~RC&B5;<(E;&-qluexk>X98AhZI zgRNC-xk=|k%=*M@5xHchkf?}s zSm0_~vDFrOYh#1JS`q8Y$(y~Hp*gU4`)inW0Jl1$sydLa`frG8rA+n5Ffq!++^4#& zYAGOs0;RaYG8Iw*`N9ONMXJIFK~@=WLt^Dlp^_3Sda4Dh*n0gPPDPN^KNAXZd!ebl zs$U2GwjE$0I=HhD5diAtc(n=Upm1vUZHA|E9Y$Pp0K2qtZP83zjay|IOv${=x}U%p zOiIuOL3A7t1YB;m#1)W5%mvRK zs8FLs-pjUK4E%6;2_oJSI_XDeDC!_k7>+g+!f&`zxW+)D_Y=4xPHtRF$ugDK^poIF zRaS*dkTh7N^zT6cG^_-HcWY-f#N&n$L5OhpvnWkk}jM$O$oxb>T)R%^=7DQyD6P-RNSD?{cK# zKI(*SX_qhV*Dhvw(3Pg(&u_l@24mTw5lKh*LNg5pRV_`xYJwm~u7?&<2D4a=gt{s$ z(Or|XSt6!rrN1nPY}k$BGac$hZ|-%7q*KOfX)*1$$g zf?68)^Upu;GdS?8{v>@2r}{bu;DKI7-(+|{1>&}6Dcmc5; z4eUZR7J&z}$ey9$5OX#irB%#x^gGnxs;2gBJnUE~SjJ(_btzWu}#Q4-{_SmbfIhh!gjl{ShlX?{$w6Q_vhc5R59 zencIM4e0GT_JZ#+V;ft$fGuzY=~b(%B)5J#n~t&^9))+8b@0dhknZ>uITAM`y!NRL z5mK()VniyH5Rwqal3orf@2h2j+!0USqK(^VfMnZgO z;s_AUxNQWW#{@kkqK7|%IpzrYOFHf8G52f~jKwQ|vQiI=In+K`-~;lk^KxVFoLW}r8Y!F{ zTM8G_nqLNDCRSl>BVtCvsb{Hm2x$WegTewncq3rGEP+PxiF&Xeg`qlwpCT2_!9ZhS zrc~=>BrFC9P-R;w4Ej+n%MgN^j`WAF(mmb>F~)TlJMq9dnlF%~nR>tzGnKaV^)dMZ zK^x8#Bu(W7i-za)Q@8r$>FSLp+!D6tLjoP#rOA)Wzk77VshjMMEDPUZYcC;ejk@tbF3OR!4eUq(;q?+D< z&`j+i0=OkPvZK~|-Mz__b^!Nogm;f!9#C(qOVis!G{XP+>;L}OfB)4#ACuZwQS5DB zMRCWrZ~nWty**UysF!b-wh!su_v3G7A+>%18xU*9=PN=yt`;81Bgt+&_iKdGYxU)} zn$(Vg_TKh}JDa9*qL>kA>$c5HhkLE?Qm+be)-=Ke9uMd`LnlsfrA6Mf|ytDvw zaqOi zW{zHM-{hH4C6gOESG!XfT&eUto;F|Dhhn|gXF~q#zBeLLH&z3`e2qFeRO79_io5*Qs*q=VYpem}IssbZ~GGO&zb!r84 z#o0-N2UHDTW<)?+MR}MWHd%yJ*iaJn7E3YIpKYJqCkF&iI03y$`J7aWJ>EQ9$)0DL zuNRvkH7rqVI-u}f%+yeLPa>}Gx2xvTmZ-FhvkU6JSGPph}3l8Jamp}B&h$Ost9%sC=8u5 zeIPGpW>C{~{cKTLBOUY)xS2hp0e_^^h#YoZj)X+7j@PcxVfRt1CJq9{5z_%uqx!@F zZ=^=2Lxpn9Wu#-8uAx*{d?>}n*{U2S)L!(CndPEHalr_=kO17w*nY+iK=@`61Fwe54U{6z zzRO&cU#FizFwegC4zP(r5FWdw4I$`xyXlQVLU@H9nvJCw`4Y5R2WE=hv(2+jiYb;3 z>4)Z^7i@q>ZA{ z!#C+4i20ZyGrKn9;3WY&A&=dTAqW#-#We7y?mE1f*B2zac=&Tp3Veq@*=H9=z#LGl zzW;MMEBol9k6LufDcDI-BuA~O*|yxV7{ztsPl-KC;F#*)#bRTqbUeK79e4?_MCn)x z&4otkUwlRqQX_-8GRUsE?gN1m8K7{vl0Y&XTS-SHtYjnq39*V4UYsd=9ZJ6?404{6 zkx&{bDKeOhCET4(G^v-_O2^5&=ZLbhZ7(jDR4f z@wI$8nz@w%OAW{P8k`Yu#M^wT5s`$?0-6L{ib2f6^5hPPm%y~7P*OlA#HE0>!w`d- z9kw8ti?skk@Nu5|!*i^l=u|w+H~q$E(ve|GD+t0Ir92#sR1L%P`Z5a^8N^aN+P$Y}bcAk~^+zP&MQ)3kbys*tIW zh@MA`nCasrJ7Q)_^D-dwpusHs}Y6OZ(Oa{+>LO1kqyUMRq%2eVMS)|;Wf$az775k;NAh; zd)v3c-`n0E3wBfjv-4$d_pJ;SLT*CgQ!_&EkrdOf=t!9XPj{_-O3f>K)vbHlf!}+h zwn+8f$e-8sqspQZ^VD|W4)TB9x9s}DW827G-Ktd*zk~a3;WJd>p8PYRYT*?-Apm)* z+TGJLp}Hs|>_zU?^RkbYlIaydHm_=4ycASxm9^Ia+`ef($7x9>Fr6&l0xj@-n z0T;*F9(bX*z^eHqr!INerz)pXEO@a{sY9>qyf;^>b{8lHv{$ZC`5`#3pL7D~cya-r zd;9cx+gGamD!Hn_8Wd;!A4WXPv1)!_SuuZCLX(Dj=thJ3znwEfoCAx8QQp!*pTNPI zl!7?UsPjU8smVGh-EtlR%K^`V3Bkmv9sGboJjzdYt2&IaP19YF04myUZ0RFb>oY-^ zIC!yO;($0z2P%bUL73^c-8BwitSa+jyXZQP&-g}mv#EAWNEryI`ZYQF3DbxG+M5aW zWt;6yq2GvJbWi7i3Vww+U+{5>ksIp*%#D36JauYE&qDX(~Gjy;*}bA$x|z{D3yFiBuLFB%6HDM_A{8 z;9w9O7*r(UOiwz|2CPoPPZ$WNLUJyev0RKo&#zN92-7D& z_C}VF(?E7evZJP6kw*Xnh-y6$?YZ;mKaH95w4o`nw|Y1owuDKedDapMM9buV48t( zd6Yrtkn!zboCQ)ORACVejn1JL8E0E^!Ul1ml-3Y>Xyax-fk63_3h5t*ElXgQ{6$#h zMQbc`L^H#7C7jmrHD`@5HPYyPf~Uuei**g-#?d;4RAlrEBdnQ>S&>RSrF=ol+u|1V z33qJ9O#GCU;LjN$J4s_|L!mXMbltric2sd=h#>%$$w7c1(NW_{flM%isbz%S1z+YG z6uJiYwaWl*YCQ`A>k}r6=Q{l43oU2c{KaenJ-;F{eOfdy)DF7Lq_k0;kjLmGrY>gc zE@-_XNZOQ@O^t3b=?g~KD6}M)K8Gt%gyUz}8DTmgunbA8fSG0_@ikoe za8e|Qd#vb?dj#DB5v4j@@515QPIeIaa~^YwC5vVh1Rz7Gq)S_kknR##1nD{z=kqXn zv8gV<%M)oNEoxGkruTcH3ibc`iz$Ar7JDN`SQyzM@txC3ojPUGzw)Y8LzB| zu>4%9N4p{hadN9XG=cGdK!Xr~0&F+7>2;;sQX0R8ePyECp9zDB^PP3o?D+B!s^5M0 zozZ0z(iQF#hO-FF@5b9C-7>L711Kfz5ioXzDdq1ZoUBFTS%|5DGMifbDJIhZx`hK7 zVQVRl8x$q)K$LxFqDU@SIS>SxL?3dHV`uXjAzNxW1=qPT61s)q4X<#U4X0r88rn%W z#t7vyG^FWUT|ooOP-MjvhL&0ekW~~gkJXqTXK%F8HKd=ECpyU~5(Ilb<{nR)e@v-H zWSGF%TJMtGMiug~?&L;{ajeD&y)jugLVR(Xz{2;)wQ!9RQ(Qu2Y=;zkRFD$j5ku$T zITap7CL=fuRIe2xa6?388u~N`cX3WsjeQCfY{}e=%Of&ZYD&53A16eO79r6b+)i37 zmZdpHp~jcX6EL@p*||Mb@fz|CogmdoW|sfv+enTkah52jporvg9Q0+I?dhne3$X$L zDHf7=N;bpU>xYh_g!c{iOfTsZvxL8s!J@}`ayo{6(g40w)|!B2VyML*K~+j3pEo<= z=aoFh$SGRyPg&q0^FUi^CrEne_VDq9Xl(&v z$dP8dfTC|SLJw31N)zN(e2TH~fhM$^mrEj9-rVi2Xq9|H)tkOwUBf;JC@F~WJOxT* zm|4_fy(k+N`q+kR_-Ng_C6_@8{0`&vKqR6XR93=5aE~VBObaS(m_X@2W^$1KlKnO8;V+O?`1^C>UGcCiJFaq&_hc{&uu3S;-mbn44?#huESf=7UER9?UYqh zY^}7Xqkn^@A{w&sc>TfEeJUj>E2;kVIdS+0y92UAn8D{=sqiqEv`&&ry-KFaoBip| zy^?K5MwPmoyx`StJ2jO5m39&o7;r9 z%D6YcCc?`!mvG)d6ejCTxDyQlIER|r6xi9j+KMQ?4Ue1Q@dD=CqY z5uv8c8sE$%u7cNLpy6i8Vmkx2XaP?M&RE!+XVDLO+L%0^CvmiyEc^|)NP8%n+PV>< z>4vR+8lP%=d9kXuyc)Ob|980X~@cLuQF2pe=o;0OrPdW5L+~cEcXghBo{=>{%WNTq2uN z9zYvJIFt-PCWDb5vPPf~=$2Prc}3-bssa5$oUjUIOxPN6+w~hCd|I8TA3;x8LqAJ{ zQ+e~vH?a%o1X8DEHv%II2MnUbW&thDsQ4qib(!691<$q;g@&^r12zbgotHtd5X^zN zI!8%1Q@5A3s-mly%A(}oboz(@ho|do# zmWEY^V&}6);p%rcz^Fhujp9y2Dt=b2aR?hM5$5V1NYG$&ts?KPRwguB*c z*7IDb51;B1O6Pi9E^)CQ%51`%(b z(?kr~KhIRi6YwKoV{=!7F3v)mLBj zSb=!7QpLlq3co<4%M>_;W_C6%$2<3!WD`(?vhO~Z)dDq^VYNnp_*<* zU2)QReM*o#V#%NYzJ*zt65p)#tc5~Ps%S%`#}(I4KRrH1c9Q+6d$H|N$URh^;MNBtRlO`$;qmglBNqJs|g(Y zp!C&Se0Ggms?p1_UMN>O1<|P#)@m8UQo&`CA>dPP4VW)h0;?niQ7HECY;pA?V_HFl zFk*E=ZW#=g+qNixod3d&`srT9l59B-VwcA4ZMqknC0@GtqzAJP9^Yu8T(eQ~Jb%;j zzD%rnWzKBO!1mhCv09=%UY%F2%dx+5vR;Ab6B(DXcYps0@VCA0*<^)A03XlzCn+y8 z0naVsrE(_rxXqqQgj4-E$-~44S-VUCv&oSvCK3vrMC+#RCl~Whi?E&|ro7hf zO|qS|U|>4yBNo0b#2G?6yj0cHEptK;7h6do2-zqLjmP^$R)p;2=yf6agvJs4gykbk zPr7TuP4?keRZ};1LTj}-ALy0Iq3u^aO(zWWDV;Ez$fgr>lT=r1rB~IaPH15U?#kLs z&f8E542lWLUeyonpXh9fP4E3c`&3?fa*ahN#HUUat7>Z}oV!>@yG+YJ9n4)6CTKoI zt@NtuK4DdTzD{T~v-tVXPphn|3VN!lcEYNv?x${)R#ibyRaJAes;YLvs%j^!s_GMU zLg*3PfyAcVPhbam6ZLuY>s?EqS})upN_>qnCYU~VoTKv!h_B)8|BuWJHk5Dj5)G`v zA3YXDLbNKt*i$GhTE#(xI0AVxHVcahEM#0Sa6zoTv8SYu*RrG%aRCG58ylp^kWDH6 zxS!S%#8%`4CVHOBTy^N#`*!_}E06e|a7_2zqFHk!Nu7dkN`Cu-lx$F7eG~@ts%Z?`Vdxw$HwH9J!pl% zQ~}GzWpZnctI`~Uoq?UE-QgsFKoc8O%Kmf`o=hiyd;8Dcy{k%cU11!D!5|5A34|00 z6paw*hLDsCJ-{Ri^g_m=pT{^9P6WorffF0!%!>Q~j_e=&&0^^4s;)WHbLLE|Hp8L1 zrfToC*Iu`(_3c%EQB_f)I+L|t?1-&772!Tx%aA~dq3E9aXu<~++P-7I>&4H!Vi!s9 zs8t0zw~!6}6qMN~L>F|ZX0bhb>e;Eq)^`wG>E~L{mdOQ{3FU|}M|<2actQ*2Bl7r* zDa6Q`Cr_R@NlFY~!PD`C`t38(s7%EPqLs64LM^}^9ozf6Y?aBd6RH}x{?Z)F(|HGs zWp!QEFt+^mNvS*+J&MU$iM4Kh2p(Z*`1I3HDVvh4Rhae7eS&CFtH4W*v9&E%NTsOG zSY2h3v+>FTFg@FzknyG`5u?m27CG=)bAJ^Rpyv)4Cfswd<{Vgx3dkCrCscw!No+qU zc2#KegcyrOep12?lmIlM#X8%q!r07Wv?Me{ulnYakePeNAV-N%m~3;zhc zMJMIrOw+L|jfrtF!QJTz2}wKP$d$z%MpOHz1)j+Btm!msAmx-utg z^JnT35d|$-J_e@O67*j=%?U>xGrdz%nif&o{_AX0F}?%p%1&p(TU1iz46XJPJJ=Kl z?6IXXFPtU!j;R5?)h;TbXB%7=2uwdScV?SopW4_PK@M$?q{2~)&0h3hY}qIw`uXZ! zS>f}=sEK5wxkXeO9I4w%$m)||&em{#`lgM6W)#Id882i|6<3mPmg||6P`pDBM~kLs z7RP4P2}hAFIPw$s@UY8f2`(z3>MEqgflB`Sm5|_>xX_vto|B>mTIcyzF!l6u&?p4Q zBEB6rw6mRauu8ecE=rpcUJgfHT9svgwK-7g9QTZ*cYvLOB)39-U{Adf(jC1hic^V6X)ke4fTTAQ*pRjODqXzUtdE`+1J0fI2p zl6VF)4X#X3YdM8L!dX8#77inxR~c#Q!H}~<3?cu6bWesnBwnAjySCwb0z{?o zLqF})XSq8ziu%WX<}C$3I>!ixke3#@t=TriaZQFsKu(*rb_P||yfFnukrAeI3&#GL zARRDevta)BznEd05W^xkW{E^ro?e_3`=L|ZC4w+b*HAM7Uao%GJXWEHfyYb+7*7y` zRL8;)1bbX`8U72Qd#EG0I^1}f?!m#hY=k-DKrYdm_O*;Z}9W{vH|bi^KitR1G&7VF3{_N-JOO89k1v@8w& zm`KqkIz4b9Z*3LG2F~b8xND}SApZKrCF_Ot7V`LJ{R?dj5WPLVS$`mA&Ohh)@($n5 zbsS3gqT8W_cWS=Mbkj||E*?1c${g(>w_hDBINH$;B|O^EZlvA9H|xEZ4^_DFnRt1d zJM>%)|6{$Et+#(tBlk00DV7r`Yj=i#%)4zk9d6gU<&5Py|H5;vb)cv>Z(aUOHy?J#mFXC0ouzJxtf1M^3Bn|M> zPd}ZUmq+OXw>Q?9e&7cO%$!WgJ(ahRV)n7pKAXsKNoZCEe@~MVb??bZLizmQTDdjq zH@$5%$t6j}m-Tkk>s(f5c^*j!dqIQBJxY{qFlU=-cLQ**fLCs?nb$TOPd%ZP2Ji#e z1$2^pw>(%DbeJ47V^Yt|m{h3?K>J*9(u0KZXP zZYgaB114)}aiu|xm>-a3md|A}i)vt?{&Dk?14OQp%<{S z8OaC-N;YLb9m7CKwd-f+x9m&-s6c$h?wFA*b^sdGqD0;qquRhi(9O}feLEUn(V1MI zdASl#Ou*tq~RhUa_rm;%Jt$#{B(m0W#BY5XJo@2|x&V5<+EMrEZ;+ z)D9uddcp}<6vzj+OT=%4D(*tRdC3pE#S{?SXOC@8Ak1yS!#d0t0?5$|+~m{YV?<%j zBy3#_x@4wg#{c>+h=TAc+Ye2aiIpK0j;A4;Z0z!c;=PYQ{upQgi?KdLAD{qbuOETs!j~DtGrzqIc6B@{0Y-&3XSayI? zQchKvnV`TxFnR+l!fwhysg|BFTrbX&@7x{n#c{NO*FYQg6*n@v(-UG&xPI#mjDntq zpw&<&P+<5IF{pz3gRuaPGfg01k4BJs9#%1yC{tqq4*!)WBrEm-xCVw>C?T+?dtrLb zJ(zp>ap++%j-9)HL*7^s0!<-<*Ap+Z9^f*}lMm%v4!{x0(D{JZ_{JRI7_ci+b;9D9 zqo+z@z3mCv7jV$_4U$oE98lX30@U#s1|{)9e=(M6u;RoL>lk!r?DMFfv(05bXHVW3 zJ1BE!%msg#5=2_22K7}xvoO;bLQyrgfFv+6=x5w5B5Y8Yo1o{R_J|5=waw%(!%1xO z3L)gcilfA{;xhxdu|Dd_E(5j^Jh-uK3N<2#t-6K(OedLGvV{v>0N#mZ)@&^tHBhRE zJM)u3TYieyJcIV?hcY{0au}YoN=W)ybeJq2W;Vrr|NZy85V7!jV80SFC4Bz;#J2)( zB0{%y2#(lyqgJ+M1nL(fMEhdh>^MDCB`X0n)r z`DB8rX2>QSovs|G=j;VZn)8g8IVMRTee_ZIhb|*349&I@23VVLR0Lt%gpw_5ls@SN zHQBBjc%`vftKleVu;fJr+8n(7_SaO6GYh4RK4}qTfJKJw}sfh&0G)M zH518KP@z~li_**ICLXVXybm0o8zI>Ql&yhu=dYrf!zbwEa(ncKi*lWqn#kX9+|*ZJ zeKn&j+1MMYciwr2IKB7YdoF;4UK<6k7{?O<)`>TB;M6p{(ATjJNv5Hf;=w$zjC1iU zWe;`ryz%;1SSwQa@WT(gy?NEM$27dsEc$9VniD5jVh;~vWXo2xcAzjD+#t`!DvM<7 z?_k*Q#Y|2*!DSJSFo3|ZE1hIo+{m_x?=86%H3S!775y)p!P6RDltrtjE7B<{<303a zpb+80HWtB7D4b=<$O>Y>h?#JcN3(1Ay4m7o75x-cxRPmS;i?(oBrEO$6XBXX!eOq* zJe{x|d%{sx8NqnMf&>9KWe;+Jl@TmdGfja#i|)Ymo1O3m9|9J$Yn~e!enS_=1RXXz z6y{+#J$}_+av+>@yR|SDzWwmS54g0`6LJnbq1z4DPYwNC0sAdX#anE&0f(n@S^L5H z2hPo90tz-huZSKr2o!g|n6Vr}K2l`cE=E}b&ow=HniLmpBA$D((bAY2G6Hjxz_9p~ zmIw1-#t^SfkZrZq2$LxixFVL~t=_C1{tM!;m)hek`623_o_fF*zgP%k;SXcsV&u6P z&*_Zo(V%B^3SwO*W?7>FGc9yn9qyrrJP^)%!j761hMGKfiytO#=>hVkv;)AszcC_Y z?uN0WbiRkWl$=4iK>#;PL-8_Q?7b+Q4e$WK&K*klz?;y9KNN|HQEwQ)%}Ma6-d}h+ zJ$6wXN_gy|csY%qIJPn!N_Z&Y(f+F)0Pdq5?dAa7UV?cy)jQmW)`AB-D3ZKZy)Lrc zn|;r9YfEGLhtR*E7U!>{gwOfa!fKONE&D31B>PpmlB|=|k}$m%ZE|IjqR&|yyBewh zB`^%n$$#lqpomhkCoQO;T;;R&*;3wcyNU|P>OnHZ`Dbp+6ra4DgS$1PNV!}+;c>;@ zAjPg&fg5aq@p~S6rj(<+f2j9xe0OfpCzD3I3P>9Ne^JkbOrezZojHGnZ&65Ii&!L) z0jA_trEX>F0V!anWM<|54a0H^IHin3KNCdhOF$=iTd*K{D4`h^+y&AV5~dR65}@*- z?7dut>*3dkuYo{vH@+6Y&&c~z8#!wiyV2+y*ULB?3lgg2I01qR;7i?Ds8ExTl?!ec zj|(_7sa0>Q<&HNxU!sd-nE?85LmC>#3~hoH^OG0G6WQ%0*tcCR;A~R~J2X=Tt;G?@ zTdR+Vw{FC=96PoIh}Y!z;1DqUEU7qPjtC3MP}gXs(ByP3Bq_TZirfu0W5I(0s%!7(LjubqQIHbwmha1e^mW*S;WP`9k(vM65<$ zh2+hyeWaFEHe%{wS9-7hna&EY}g6#0V56E@~-R?;H9wJnwjLr_1}K`Ejb1Z zAa;Z%C?-kGMdu043-+>#z=-z*s~Qcb1Ren_5|CYUe%lkmc3^r$sy+z38VoXs4EWXA z0_4ik5-j3Eu*MT&Dd5hwv#PE(0U}sv!_^`eY$SzN+679sF72_&;)xqo(ex7#$6Wjr zoG(+=aBaj(hZcys2B0Tx7PJf-vMX>mkUq=G*&LnRF3#fJC?V0Obu1ro9~Ag>JK6<{=o;4LA|7{ywaVPvfDaeY zK=iO@^dVq6%;6{@(Fusm#Il-=GNwml2-mM++_n;0W4>r^p92a;?NK5YIJt!oz$?;E z6DMjjFLHq9i%Q7qW1xTqV+jFBpkSG4$gk)twUkEFcATenSb7${F(2Bt(o*-E`taW`^?? zly0{>gsu)r83?KizKdOSbyW$?)6&j89l*P^v%^7~XkbSZXw*=oYV0xzyg^?wFbv!2 zAWSgDt~~ziMd~DBnrqe?6t4vnR|H+R`ysxsH5c1S9Q+d5j6IWY&avj3Z@yuKoF_x= zVG(yVO3!#+UF?4*WT(>{?xIJ{JU{MRFjjNfkINoEerz$CxOp*Hp-Iu4hIA!2zN~9Z zM0h_|&;J^W&eRVZPdM^~O#bpo7zbROxKMkmmT~0PPA+Dx2yxv`B*NA(ZEXT#G-;`c z5Ni%|=QE+%6^x{WZqLUen=LRY>zlH%O@cj}-QpydQZVKGveduVAty>`eNC2CvhF=# z^R&VQYSR{qK2^eM)H9pX#~p}@DUY0=ZRTax>3Id|9TfhcgoV|jT-Yey9?>$Qdv_)4 z(anS~5`qVvX1e^PzN{2EMxrGY)_eA_Z-^UV!d(w-h^;(;V3kS6Q$8BI86N7VPsibF z1ylsI2$LoNI&Xa4rTEY&7TY4}memBGr7atJX!4=1T4NT08+V5E;FsyNK@myd%bp6b z^8NSU_YhL`@tsOYkw6dyOB4fgJQYWAVxG)e7{X>Vc45T*G!|3HdqA;Uc*dT%FF!xZgONFZIWD22>9dkcN3BsX z3+;ae0@lmxR^xZNwLqlASap2?lW4J*sBn3^TP8EZjd zgvx$WU9)4d&pw8jLTK!iuy2R)+is^_p3DI(ResaSvNKRgiSI z`!8d8%2yLM9gGalL-d+ zZeO(JqHhv0Q+0*wj!3J?{VOV3kB%H`FT^-@UiVrZa*}L04P*#8v^(_RDB2l(B><06dQYwwQHjM>X|XU<^Gx{b-~9GZfBx&qWwJ@-7+*vSKy*LP zg#0`d!On}BhCbc?vRZ|Vwwvtv{%68KHp`2H6WUq}&s8ACBPjtc&Qi(3k0bIz|IIhw zTvl{e##`&S1~eld9a5RWmNR7$WoD}yY5^()?!cRC3e2v{5icRR)X()&sAeh=%%JwA ziv4*Aur>UV!j`Fr1dytlud!ntHtMZ`%nqvInXt}MCT8jwZ{2PqlHTADutw8jE>d*H{RlTwe9wd4$s*ef5YK(McHQ^b&EEr28r_h-|e7mCW6ns zxYr4YLMw>>=38Jwv`hyc2pS3T{(=g)*TFKFf8&iel8qWzGGGegP1X>vswW)msPHc= ze<(IA3%@I9|Ks2O zerjAnH%QO?bPV#st3ck659o152$OXa}0=h&%!LJYoKLH&Ai2}N{+;&tIVCtlh zCM_ai0e|(cUG#(?7ce)3RrOS>j*MW3utZn~xG4S%@=k!*o&BEBy^v^m8(<5R;~w2M z7c_hE4SV|mBP%@XxfXIC7QwxqC zEbdkEgxdgaZiF=r`8G;uZQyjA5BQ5yBDz7TT5OEihGKK4#AuXonWETMLb^vTtAolP z!d@b&rG-i*h#pj=M>&Khg6v>i0Kryt3I1#%(Wa@&O0Zy>Afc@Nwh{s;Rr-EvRKqlr z3_Cfx08EtJ2a?q8poILVN1f(HK$Wt@?X^GT@8c3FS^W5GP8Hi+;h*ln=|)! zbdn3R}9^ ze)m2*;Y2ygtGD;*cnL>Y#yA_uQL8gR1F%sx2dcrddbGsMThmu#q>sx_Elur1g&-3T zavJs{AA$Le!NO0|LedU9J3EGIm6jepuyX}DJdGT0_xAQARN0nbTc}%Mq{h3F<()RobGuxkDI_gq|8uJgWw!QhC{+Ds@G-CScMPZ-qF<&C=+1F8^$(bF4EM!o@cM zCjxF)SD)^TgrbUKVmC&HsR8DYqM1TlcRlMvJDVVoWv$IOvYw=tc$MuqxopiiZ6i&+ zokM@sT5U-8676m08Ixr+l1&yKIoDlOhex)>oSEG;MtC~L0m6B#vWkweN89Wc*IA>HM#3H{IjSX#3K`x14x2W_OG*fxJ4(i?A zcW$g`Ug@*5X$+IHn0w(bRMG^GrQ2jWUz1*+BubUbo*6cl8q@<%uHV# zFX?EjPnP*lolyQTt+F+vsYsX^$P|$;_?|`#Y=^EBPPKN_w>VNYGq>AmVmhS#(!6%) zI$)Co(e6P8lygr(F8ODT22QtE`IlsaK4b;43g zov@TTVJW3fSW2C+l(NzZZ?=lXHf!Qi9_ECnTgBR>HE}7cnfYIVxKjQ$)vPaN@?XrD zEB>@1sDCwCDUaiXU%%aCao(D&l*e(x>n}gf&R@Scdu5qwx~h~<$WYNXsRc{3PH)#XzFOwrN(0ibT!`^ilJo zo<*UGG8qwRf|vv)?Ig`hTbE`f{ZL9;LTK$?%0_cSPDmDyP_FH$LlZ)iqmt34S?3S* zWb5oaK0fyP@((8`CtQ)lr)^?@wRI-x*k1BZu)UN0^#2(|LG$Y@gM98O5{nF^$h zXl`@G8oiW_oRGpUjo7Hi*7@S%V-%wpy;F*dxgbU{iWOmuViYUF7{w??u_AnX zv}ezrRjXDd+I;Zf!Q#b>PoF;hZgtzgfB*93%ioFUbp=ZyYckkYP z4bjxDU%$S^(W6HvPMm0Q`}S?-yec0!(zkElSGqEE=+JH3wk6tp^ypEoTD9Wd5i#M$ zjT`Su`g1zrx8Hsn|Muz0lPB^26FPP3RN=yfpWScPtl6SPi{6lV_3G8vty}ln*DqbV zlqXM~7kI<|+jC>;)Tt9PDO9MC_V}ty4j(>Tym;|fy3)37+gY<_C85u|As+ZY>V)W{ zk3LHHI(P2eK7IOh?b`M7<;xPfK!E}?X3VHvySAT(4I4IQ%oq+mfBt-tB1H^%jT<)x zDx*h_u3EL~?%liNk|4=fs#K}Hd-po27<%{ajUncK8a{kDq#QD2$jX%~%akb-P)aM) zHcON!F>l_y=FOXT>C$Dwgb4~@^XAQQNfWrmhvwEu-|N(=6IO|O&e*YIwdHQzx@jp? zVu*2|b?eq~FC*CLJpb(6xwA%%8VeULG*cBVT9h~D&!1nfUcDAAT0}F&#fuj;9RAV$ zywYLt;K3a`b`0%Q6EGYuS+a!R=doj7Oy`R5V$HuN|h>JK?(jrs61w1 zGCLtPEaW0UvB;M%pQ_-8&p-cMl}az0XxXx5XV0D$TG8^FGiT1wrGr;Hbm*V}EWY{X z8>Vr4x^(HhN&@8+OyCyU%3x*VCpG2?6BW<>`}fT+SFT)X ze!Y3vE6B5m^Z|cLMeY6l_ury0ZQ3-OY15|l^zoT1S1t>X4LYNJ!)Do!0xnjkN z+^9TkB2}ccEM%9^@EGRsh0n>d&jdB&r?0>MT80FOw;!q$?mz*rTw$HauB=(JhHqoy zTny2Xu^}-qqzN~cD_71d#hgsU;YCF#fz+7GA*X-(=_iYV1q-V9P!bHYmRnw7u+M|Y zm220oty8B?e9}al9XocoZVC#re*gV<3ugTF*IzOtVx^%mDP$ycjP`|JG9|@dd1uR( zEgDC8Jn%k4PlNuNHwR|oJ9`;uN?)s~~f-+lKT zGpVP?MzXXuJd-Ly$5pkgtJNq!0MuQc}zg zUNs-}_Uo^|TH6n`0pD2{UBMQ&fb43CH^>r4e+NX;1sX~^Z=~V`;1zHID+`Tl-@bhj zLlXxq2(={rv@bheAsTO3LMJM1KC*DwQ}9q+(u7GkAO6^&S&kez)Rj^h{LmW16e&_@Q@A0SEer91Phc`AhT@V( zAAImZaLA{hekz0U@eGS#S47exd-m)Am;zsX@rBAUw9rSY83Zs%=nX0O%P+sY_uhM9 z-y|c^LtFdv&p$<)&si6_J@QQC6M~eS(j_a>L1B}~Gu(m$giaWU_uqd%tRY>9DbkU2 zP9<$r5piwVbqWJ`9-}RWqQj_{`iezrSk9>p2Bu1t3J40Zpa;(@kC0bzhZMLhEAS`X zE%dCJ6rgHwK32Sn^YrP{m5T1lZ$UIB+KFLJ4p23^?ulpQMiRqo!UIU=Lk+JXN#KsC zc}1Um@(CTZ-yr?7&pr!tkRCu2NgJGuGm03#WE_RE>fYd?KcE1vhNb@f`(vlqsZ0~F zeUY#aFu06hW7(g)=HGZ&Zs>h&?<)v z`478xkOCnLLjm;Q-Si+{r1$awJ_r;S3W^9KKZ{{-Y?J=}q)9qU;i+C?JXgUj+M!3I z+C#K57A6J?@Qy+03DBku7A}!4WG3Mge7k3Tc1Ka+;9)i=|U}=!Xzq8=)4iUypN60W1bo0 zDdse`ctzd*o=L(2CELdp#Vpg>6w5?lAah-|QDj2P!H{p=6QZvqC)c$lvkZPHTw-QT zIuMGSTf-5KS7a~FM9}1@|GBIeUC>U4CR|_vZ zXCfjc^BCF5Al4vm5@Za94Dx|5tUS&>-MjDH_wK##zj*Jm&p!M7oAqDa-s|^Y`!6p8 zTZ>Ab3^upq#)Fdw(;nkGcxYr62hU%EN*{R=eYM|u#P3nt1?MYsAjfcmA$OhR@n8?O z=RNq9rMQkw0(frc1AIuRv_mAzaw1Q-rO;clBA41`0wd-LuY-p}bUrA_HXRyX=_2H5 zWW8L0EXl<^Avnm9oku}x?mTVb;Gr)=DPET3fr8Y??w(Yr)1Hu|87c^Hkk6jv*?fP-UM(a&e*3&^bN8IYKY(E7uGj;V1H&&Tk1GAf$3|kl?~V z8;+&Fore;{M2071xvaV*sbf9L(n{awY$aZ$MEL|cahYq`f9YD=9pGKV;z$$wiNAjC zTjP|LburKrq>|isu{r3Efi~j5ys#-?Z1xy`;4_c=NjZJYjDZ@2Pl7an!Ue68N!OZz$@p7t zR{{otxmZ~;&~_&il>9KB5Y@P6%37^8mW&^ap$O$gx%@za&C z3OaJ7kMM@@lEln_2bS&Qbb+N-1P|r(lV@o#Y>_wL*+hob1dLW_!S!TG%fy~Mx4+CV zEP;$ahVvY)4#6hLj)x4%Vu^mqsw<0Y3`w&Vnq#C0CS?(UD`H}NR=vIR*hQGk2E)J{eC*mvkQXj32g_Ng)LNK?Fe)OXlWLp4$!XZ2Ho$q{ySi{#^ zcuR$9htQBks4FN38WKG3jW^!VAVtE*Aa6W?@dIJ_3*I41HWrcez)ZUa?3lDcGlcuW zK70u`Ohg<2d>J#zaD0-$wJkG{N1pJ@U;Z*XVpQ;idOQTO0S&m7rH^>RfVdqH3&X%wIt+<|Z_Wc}6Hvy8 z`h}3DZ#WEj3sGeGLI}gYy1&BbFMQz(bmJUeX(ym21v7C_?B@HWFMY{DiA}T9yS}iI z-G}(m^_y?L=>ir(Ujam|ohyfv;y?}73pH+p5SRXf_>_13>Q}$YX5|Uvm1D*Q0EoP` zUcfFiKm2NF!2q9NE@n#hzVVH3V8RysIfzg!)hsbHNCPtla*MIXnM9d22|w{*M&B51 zb+bChl~BiQjNp6l)}4fnasa18+}UqB<559T=y4?^0UdOupF3cUG{Vuh7)%a(6R?BA zFyy#w4D6*Yh^9py(cGuOVDu1T8t0nYPV-}=@dvl;&2{MPs!B_w>{tPMk$umgz> zSf8aUf+Bx>7pdwa%f;Fb`1>5v9Z%A+rYzYEK2sjYNG*j47YT0aBxcT)?Hus)IB6Ec z$*zI^Pa&HKye=>8K(xX`ri9SZ^gLnE9hVbq3%AL0TZQ;5;zx!DFJ6yuq}Jk)5^CJo z1S6EWfsqY&p#y}}k?u}}qdMcG*$J`ApiUv4*?U*-JYr!dXgu>A-j@QjMH&?eAbP?< zvotp8XrZPu*jm`>-L5F{Qqcm>z!o}Y0*Pa-O|y(QXfh!X($EBaf&6x0B(J>kiow^2 zzqp_~17LJnecX4=3m!zu4b5_B45$FD z?Pm2jgP()pt!c*020Ry>2uC><@i)n~7{dLw-ZHvEc51!7A7x!!&&UZNxf<8ddjX`l zl(FOkO@`AK6==C|*LfTfhB@A8kkQf+=RgE~)KmezW46R#L=L)RZ^T02ku0ltvdt_U z8t&*0gE$J_n^0z@Ob642?9Q7MMr%j7m8Ug$oYk!9aEu&L2KyYcFs5T{+zS(EVK{B~ zgh#+)dS@mXSNAit#gOpF@Z3K*??ysM!;^~-HR~j&Bq+cH4iJaM5!gZ+2&LOv?U)^I zYq>imf$Q8LY>5P5qh9ogMMf*UCt+rR?W}|8vx6de{Gly8VQA)dc;c<4bbF?+LL_Se z9DgRe$b(fQDs2-r-?k=e!K`8T^Mve+rPQhJa+_UgJl36r5S|@QP8U|zT37-e!g=!G zzS=FHxT!5;zSSf1tw+xeM0|vYFVQluZY>=6h>ZmZ*ut}s5VB1|{6x?}DJ)7o(ss*X z@HAHAx?oRT$%~3{m}wH|iG#HkCktvU!|YT@B0}c<7Gh@6qD4yy7cE*!xMn%R&IvSKGG^wyaKfpvUYA6g}Bk^_sk{N8C#D9vhYEWWyUK=EZxafN;gY-$_2~Vdi)(ug_G1i6$){Cgydf3 zuLFN82bhZv?yN@x0QGWwk~nivIJx_Z;mKTw5l0SSm(JW5xS&8mXDq+Vp#Ja<6R|s_Y)cDCfa8I@9r!KW zLp5p%6S=9d@ezXICcfqdqBS*kMS2Z@<_EJpheU%RRQ#0}CRlI`nGd}LI6Tu6l8j36 zLd=mQ@Qcv4AQTpu4%k5gQBhf8Q^ql!hAp;8^bK>wgqTLVa3hpv5afWV6xD$J(8Ubj zaZkm3)Ck!^$1m>NE~a?Um8Rh1CqMZ~jHQQqv^w0EF_U0W)xrdy$cpx)cY<2lpgMprLvtMz62*`SJ9 z0~=8ZVyWK`edt5I1_yrCN2`zFBwr}bk_PC99HQ8eLm)DtMirGL;dTtNO}IfPnS(<$ z;8PRdx`sa6ncyRn%{sFwzovsz)PX~KLAWv@lEwM!_*;Ve;L zJK2ma!nGd`G#o=2If`0|&LiYX?0|T(5}QKT_osvm((A9kt|@%9{lWpnEMrL-!sbrn zCkd~ucWb)q(x;Y_tdb4SPZN^6NMyM|q7Dxt}8 z%Y;l=F(SKMBA4Imq2T{v4NdrnRx zTQZ(dETS#K0G)?p#@0?0C_H%1duL69(bO6(PwBlgE<(ElByS9d@j1&YoUttrme8?$@X~l6{$4)Nt!ic zR_GPi^v-v_vj&^dOfWv^PTh2#CxrJ%&XM))E0&0m7#@u^JO=mPVQ-sqfOwZAKPITe z$>aGsnn6xGypGKV#!qzUJ3k*SW)$S_6t*;J-qcn#_VckU?>@ho}Id(;i z#LRH7y=sGp6f3vcQPZ%n%$U_;S{UGJoajkRYZI!7h57VR<9O=vpb*af6cQ-}qPGl& zJt-&axbW#ruiHPS$nbO|@sS@e3t4}& z&F-XW$A>0v0K6HqZ2-n&g6#pnexCQhk@TL#wa3C!^jq*I9KTck}==uW}C<=#^0!#z_DwQnu(!SL+!^244v zwXM!ILO3zD5H6%Oy9~r^S&QM%ApxhBh1MaYb;J#F3;5uPfceq{62&Hp_v}ZZsLtRg zPepPtP+yoS)Y|I_^8p-G(N+Ru{6;Ry;DVZt@Q19DJ=Vuy^y^pj#0}?2K1Y&dY5`Bo zRM^tn$HWU3T6d-(Xxf|6!E^GdS#7d(wMG&ynXLCB0e>Hm#K*A7H#HAVeBD&RV4LXVjYG_EbRsLU@`JybktJqM+#dM5#Awl#N$BpjLX5zBU!*jV zP}36-l8J62Y)z0OI%==igOf;U$s^| zXer^MMRycC>fzh5@*zF@zWrntLhFZ6fEaDhmxQ=o6duSe$%1@diuNm^@LGPkQj=S9 z@9SxAxU(Ud6UmG~TbCsdk*n2n9ppi?hwRtK-=KdUuMd`L$eD7Uw~JU44=uo49D8UH zp7EMUMq7U3F6o=x{pmtaLvnyL>QFfPYrp;*|MFk`y9r0D)hF|QP50bYiMtLmM~}8I z^+pP2=uqxXVsL+^=N8huVIPY1T03ID#3|RkpGyw>_%-t6P>q**EAHuVG<-8ij)dFn zZIMUbG332=d3BWnv-Y;HLsWW4?p(uNwKgxT4&!n@iP|&^|6oS5 z3lFVvI7*_;v;2%%^Z(`x#CHxFHF`Y8oY+2jE3Th&42x z@OS^iAN<}Q{O3GjM5Y7vjbnt0aL{Yw@d8*37?&0i3j-n+0jc44q#Ow=x1bRyc&fNl zmyNR`<;pn{P&0ul^f9;ziFA!)3+zB^p&|^%x4;nelUDC|X`%y-p?B-5al?bdAPO)U z*+uQ214;%=Crk_hJtrtTRd+2Lyi0|d86&dpAS_X+jzEk?Xvziyc1uqvvwD~o!g!1y zT!U_eY>ucUfDY56I}&CtfMc~ob9h}AlYtLJYC3Qp8pkv%sQsX-2J9M87&2%2Kwi{L zrx@k>DNz|&9pfKxGkrD&{1HwYbk z9J$Qun5Jt8)fHY8;aAKVbPLW5>N1bMu$Dy9oZ6p}gm?y_3Zy@X=dNeb zja>MIQJVFa8gnncRU@1#?p&;zHHOd0sovdNK<wtz*Txe<14&6juu2N zNDvVzhYoLQ?1I_Z2_VYN4OrHUlebE5lb%(<=s$noXU~8j&6X zB+}S0V!{I|A@Ca2AR*^g;C|ox-bVU4HV(Cn*?#FeJsWo$RYhNbIy$??cZ$_gwR0 z|4b8fJ2;|`fVo<>_Us7%a3stKe+-DosVYZZciUcunOmL|^6fBrIH^#y@vu$W2V&kb zWM)?ZX;u=j6Y|*4F~q<)SUwHBsbAfDF{{r>cJc7%&?>MUKGM%FZUA#YuzLTGGe_w8 z=bvxWDWjm0B2R9#rlxGUV?K)M#Ge9tn!qsCzKi+BQ0aJhwP)Z3zyhW1Dl``orG5U{ zkPNk9P*)1sHCKBA498A{qXJfIBmN1oYACEYRrWZPd;U`)31 zetyNhU<-|A@FHzEn9BP z%u0?W!ZEf6XE+@GHk+zPL&9ecO@J-Lu*}@@#0~~8fN4vipny#9O95+#Av!fXY%yRa z)&>YJ-FfB@&#{7_6Y((L$ivVG)i6A(FS6h=t57U0*2TB>QiY_qaJp+4P2VaAwZ@sBPZZlU?Vckl)|5*$o?D!( z@8u*bw|kN*9iMuT-~#W*lqMX#1PT6GfyWmy!#oQZq^FjGokJcJGw=eCUf=iF@ou6dGF?S%3Ik4MvcqF)8#eWwqTC|jK(W2X;ggp4- z%c6%M3_|6LjPR~0AvjBg=Bg?Oh@{8&B=8>3NClS?L@Jsw+S6kXn#oziUbu z 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 Rb#eCv)J` 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 literal 13956 zcmV-~Hham5P)okJXu>R<TlyoldWQkN^s5~gmNe{<^mDZ1!up4I zjOg?$YnJ-)n}7a*Iq$dD6FS-@J;~HXzmE!f{hoIJJHPt|Z|JuLPp2mAZ7q+js% z;}*lxlXFb!S9+K8JfGYqOW zMn{2n9sBj_ODn*_02tdGJ`d|+?WHAnNO+J6P(R%TK{uA>u-2=%w5k-EuZ;{IaJ2Pe zJup0j%@(p?v1#oM{3+_pbQgaG#O9bx+$ZgIUYHGZwZL3m|GKVJ(F$JcvRR0ye_NTY+qM#9@Uj zn<`6lKX6~x0r4VL6bJyKqN%L$(5+Z?T*4{=578TuaImW*Rz~3d)bh)S{lZlZfmjAL z&fO#`S&N-@L$MU%%u|ua8EMm5vvquYEX3NCZ+faE+%f7DMoW7;0ntLHh`AFVyMIF$ zAkTrX)Zy%60 zJ+TvZ@eOt>w#cx@;5HEFWQ~=q#5sVO#wbOiHc8Z+dDtX0IqvVlT8xM>Tn&&w(x$-cCUhO0JOCMsFeDwloM@)aq^U^&WHnJok-!}g`&m=956$oh zbwp|s5Z~Vy=md2!W+zwJ(mEc42;F2b0NQMfG3h8}qRG=jMN*KFa8g*A#Z|+?Ua2V4 z)me=Z_0Fvq-3}b5HZtsTL)C-Lvx)H8QB6b+Ej_V727~b ze`C>Pnk-3?N;g(*W&-BYc6;N1EqytCp4wwgVmRnE*76-@>FU6Us*2j5HVv%PDI2sw zayKorz6+mlw?ol9s!q_<43pckSkAU-=3wDrnK8Lhp14Au&3|hHAgp3yi{=0nNnJB} zv$@^0n&G0^lucl}wql7wJrK9u2ZcjfNaF!ms?S;K9oTARiZX?r+yWkDfWhb!wx`cD z4gu)vEfD0+<@S7N!5NTOYK{*}isMMK@Qr5iyCkgPOK5%gR_@#QX0!M`VJ-9V&oM5; zmq=#ttyFwHzS&7(ot`?sJ1jFkVf-+Di3wlg-^y*l_iAIXeT4@r zYSYL2Uxvs2{ofX*@TrY(0NeP4e|yC;9Ot~VmRfwUjc-!j3ScJ)pX(dW`fXQ@@dt+0 zb*LXTSw*&S)7;!X)+G`EtER2u_1$=eFO*T8a{-WeV{!>1aIgc9Wy{XStX6DNj%w#O z)B-D3^stY|0EYo`+Y*Gj)usKdojG91WS-pubh8H=V`j*9*LN|0+cF7lEp5Wew@nx7{@VdqmLc3#Nr4xQ5Np&ci+a8bQs>eh|9is997vei&RAKvXTLHXyIGfL13DV- zY!!1_i)8S4C0UAZETyOe0pw^O&2q|wKqKp(q_BRhaEUab4v=2p=hbhJbeXCsz!mL| zD{l;xTo;ojNN&8%7%No0bp_PznCB8`%Gj(@#Ul`FX*V^O$8UEsUhYO) z!`{Lp+WH8w1zmRrm5~5tK_ZXWUm3Z_YA@&yZ^+D-en(hkgUdm4kpSWSvAk#oKqzfn z#R69820S(+Chl9Ej}QYO1TnP989ETAw`~`-zpa!_-BJ@E3}nJuEg|`82wRBNPBD3I4cZf13`OZ0bzV0tIXedZhdph33yv-W4vDi1W>*r>TSSd7W<6%YR`h)o(dC*o=;R-Ml+c!jJlnytE1wb8ZBd=t+t0hzpP&q++ zuiK4)@afXLGm8_P%0Ib{Xv3QUQSDmcXrq~AneG%ZEhDR z-J3Z~MAD96%&5x~w)V(_yRI!p_w3>ZY0|?hpEJ*t}+L$8VO^_zton{751A8tUbL+R?$tna2+!Zp9CzuiL@z zb$Td04eKWg%L3pa6o8zBPdS|M*o^|<3c%M3)Q{ooSbm^l4;7#Wj?@aZ-UfpmYKvVB52p@f!vUbNY3&*Z)ShQL zhaMgVlS5i|2ezk%jE2QqGY{~#7X zOFExyAP$p2^bIyhdYfLe1cma)07#ZpE#cd0GkQi#V5jXq2>dKz$x5TI6+dVei~G(L zJiX9$E~lk7F_HHlz0`n0u~-}u)0~>=`TMZ&OnY?7;D2=xrG*)H#Mhp&#bV}vm)}l{ zzFibhK*||B(|x8i<_hgh$A2>aA{Gk+5}uu<{)ruteUlyhkbFJwR%Gh!iodps#k9Cz zc%da{@}FGn{3WUTOh-=gx&F(2;(t9uvSY%++ILG_X<)A%Z#D3CaQxRw?zdF;pM>>S|DOJxtFJSwgFo))>vXc-KY7Q+Kpyl->GqE-C83w@X$s9CQxNC!mYCb*oVRyVglS+@8_&$xUH@bBRo7k zoHl6edRWzPIDjK>p&3z1twZC|*Z>$Dz&o-{r*T{1&!%)KVFCz?(PTE-%?Q;(gHfu} zX~&V-rGZZn(78vYFNj$wlF~PpogF`U-eJlG~e9}Xb8<70vMA4_M+w< zs;q82W#b|jc-bqxtiQDj)aLJmu&=d+JM&Z4ZtVcrr4{eg7tRH+0uf!Wr98Ig)ve#! zt6(t$=ko;dD_0tiaL|^7qCRp%(*sU~%HWrm_V4!i9)6si*pFHuzoL*Ow^rE0^_;Au z!ua)z1J|mn{`Y=XRh0vnYke%aQBG@YN~GOwdI&(nNJLS&pnUR{Ft3}|J(LWfTW}7A z1Jzt^%Sd{!u!I+xmHc%5s^hvdrV+r9C~xLNi$G6{g5LTsgr$i@q&gipB=UltSP%e+ z)iP(;+`VlA1PFD;EFB03F;Q=irOF}@>8qK#ci?>tv={c|?GYfxQ>RS?JZdJ2YX|^(#!Xf5wE7{4oq}0nW&BDvgqlJ$w9=-qq~lO-TPxC>qZkPJk;Z$Y2UP!n6Zh&(B*xF=OL6}_yHKv#0X zd?fGM;`syYoSlEzG*MEsl-YZJ`l2wr?Ilpgytw_cbNXp}eP{F11q)DEv2b=bd>eRF zpB*uBb?N+O0NoI|Sq;daG#_R*?p<5_bvi3&{~E(VRO`cb9+#}-56`DPVg5P^3>OEl zlQ;qN)J%GT1^)DL7^|VG&uXH2uC`@T_LHZR2JXoc_OCW>pIx~)88m7GP+C=WB+PLD z6AwEl?@vCUr@m+pow&FO2y??0?^5^6+V0!KX^)%Zc>tI-*A_De21<&{(gbPTyS=35 z+}!$d)}^=9A^_RFcSn6KlG{Q>mo_u{hna@ob&H0AZ^Xs#l5v19X&u8ii^4Y>cKn{O z-j?B?qm+*?X<_4A9j5C}Us#;J-T6tv;wj-XhmY}EG`ski`7u29Q-KdGtN7GLE{@^f z{%8k|Xz#3-gYU)W0@zE%G2U?~ejERwu%_(r(fYlZ+A4OK>Ff9i@zLm`htH=q$@qkS zdxseyyt6qPK1jwlnq<7+^cY#tezbANEWVBfUk)ptTT#2zL>FJtT&~T~R}hQ9LO*4z zn9h7w50CmrK}XM|fqw zu_gm}f%Md#C#Afxfo$!CW?=7;_+AEQmQ&im;B$Y`-9mL1jo1#t^RS^$m6a1 zhV*QB*e(y8M{CRURoY@0fb{aYR?5yb`s37*QGONmMf5vBbEzxWQlE2hskML-UK5Jf zFP#nSm!f+a-2*2dO3Meik&35y|6)dwlI{fJhI)Kgj^4pT5Tdv_I2Qu~zM7i(DJl>> z%-t?(=T|N_$2kea8ps4Hgz@idY`3|~o;Ad-zjlO?ki9{|hbu?vgcBmBw-q65nWq)X zlg!>i0)F&~zKuRyIf5Y(mUx6nt3V)ntkFU=h*yZ7Jz#`A$si#?LomwNR$qDXRrwO|)?17s#W{hGb3p z)*TQTW7ba%&ssHi0_!@LlFIx#t%-5I+3pnRNO) zfSNTt5;Ktz+fjw?Y~;|M2ku&M;bGRpZi9!yQvrk7rPkS$Mx{vwBjB=n zYm9Ix;ayWMXPW>6Y)TtoQR-EmDxFKKfyrQ3+AR(<3`Un{Pn!{+(7=UX}r=C3+BEYl`*_)!Ma@FVHN<@kY=;Yxg>Y<%(b zoOOCA{eKPXpd5g_7RTGI2H;A=*UL65@uPgXlT8n=a)p1{ME@TX76l%p_rs@JmGFD| zwO$xx_&OB)Uu4JDS2*FLJAha5yA?M+vR!}=riDK$tS4*9`Ty% z4F~-U0AnNks8#^Ho**5kT1SA(;d2Uyhr5@6rDv6eX&c>*E6d$kv*C1#j!x4>^d`RU z1<+99BX(kmB`pMd1-TeBN@PE53&DD{X*S9M=xV1oW9Z0{Mn(DUgGBW^zys= z#zw}v>J}>V_mV7~HA?J9w6`0&sx?=Hi*MOeO+D8~0O%-ntX`m{%uxC++`{e7>+G)= zN(LC{Dk}h3RHbyRvoh1zQKN`#ttxtc6lssrM)V|=_J8Mp&$_O$4`{T!QuJ!?B0}OPOJmbE}EeLBm~vZ zkJtWBd-vJZMv}M-{CnN|=HCD8?rfUibsC3p!UPiq4JH_Da?Uvk0U{#_B?L++Adz#< zA_yd+KnRre+!mf4z=J*G@y@mP9DdCyHPzMCRnMzNU8f-sCT|)D)Q39(7@m&yw*tfY zf+Ec;Qxu4yCZRYS#7vns7Z|T-4wW}|O8CW9C;sRVvDKj<+@o|x{I0OlsvmQ`hV^>g zU3%j;OFznK*=_Nf+gn+=61`r3SJIQ%Mt%^|-CZW_`?)(?_g0?h^&9>vRNsjo*6vNe5M&Y+-W(#o<(2x$ie8^v7r5}W z|7&W)w^M&tOyAKK^8UH{cuIfKO7NSyM{+g!b64EcQp;@(TDvQYX%*=84`)I;lIb#^ z!9a$LF_;ppRIKVdW+<(BdcAjfdW`(}?7bP!zUaiXu%5{I$1TtFdawG|{!AG&i|->{ zzpbyREv=7`|8<-kRsDzbUlrDgq@sn}+%A{13pm9LUS+*pPUp85u;k>AT;851 z;puhP0qnQYo)4BHMP!<*fxxWwq4$=9+{!uiOF#c{;P9psAp_fNWpJr5Df( zT15qS<&{ikEu*@!wyde3l{`}WEuULY>m)DLR3a>B=`3v#RCmcQDdpBni=^ZadG(r7 zc|kp^K*VYy5B#l6E@!mLTe;F)eoKL*jg{IW=jZcj<#IWdQ(H_gXp$D(l2cjD@+zUc zl}(e&X>HY#T)w1MUP*5&;8)hrXkuzT^K6uyF8PN@PyU!E(p5r*16P}V?8G~Kd+|GM z-af)*7QAcy2g~*2`|Gt1*I`|Ubsg5V>#+X7unegw{3|r_udMZTmmW<11ubm;Z&c?` z7B#{A_bH7e3#JGEV{x1Kzr(XOvPjH)Z>NTXr_9bb=&m4d3dZ+6lBXCux-fBIYK5 z&fL_hHzzHnrfylvbWj;{iN#na=$YwLI45o?Ylv=$gOED_z|PDXtdX!w8lxNH0UI;R z%B|UX*`$bQkdxh?1km@|8vw1~?-ohBYNTOK$!#f*uZ=c^8)8hwHRrj!H>_}Y?!SSL zv?F(oKqyGL+F%54KOGc_8i%7#eyWTF(IKDDiZ@2Ub2knojpq`pC&mm?m(L*uWW%b) zGRrGmz*bex5jg)-9YIm9;xT`ubW%a`7K>v|0FG0f;Yr60azb0^@a^Ou`nOF0m@5+t zX*0ul9m$6Ypdi%H}(azEtGTQg_=l1GQ0rrRQA2p`HD7W{(86z9JQUVn$Anv zbcMpx-Mv4GndQJ6d$jQlJVzQ{${TxHn+LM=1PJ5P;a>?WzkIPFU?*)Y5|n)5jc8PiAhm&djQ?|V3(fb@Lf+tEa|{N+}_Zki?2_`Na9u z^^~77X7@c9#?NH*aDJ!;fa|e9aL4F1!=TD?O+UJ*)G?6mPcA2o0B}4SzthSt+kI^I z)s-<=_U~u)0EY6pZAAmY&E?g6v-7F*S<(0{^Ru0oBl$Qtl2hNAF&%X1y_Y*_#c1^4 zX%IVtE>4^CKgP)G!I@-5_bZLdXCs+GcK^u1$Q}i<6c49-_A`|gQPN2fy;`2Q6rlN5 zy%V);{$N;5-AcW4*tw{18f4bqmF`u=E&%nspW13v>|tbcQ>js`Dh~mi&Q3YDm!VvC z3_BMTE&w|U)6m4uCP?a?1*Jy0sYG{jTr7cAWo8Z&YscGTTWzM5JwAeZlQD7kR+4p#3_3vTUCcV zsV;|6SGWk0LqwfPu&1KEg&c<_m%GMgS#FJj#A)vnjKU$Gipyh6aec>w!;o(F~7=?d_M)^cWcG3 zp0}<|kuk&^x?~@gAIt~7@I|mq56yaDfpA+vYi-22c!SqY+*rj+Z3qHQkD3Rc!Osz_ z$pT4I)gg&8-q&Vi&D=fS$y`}3jn)O*;1(frc_DF#*3kEIF%ZGli0zbuv0$6u>b8_s zJD9Y!|5?HsY+1HT7Wb+*s%JY_j8zlP_5mlu+RlPxQ!D70qU~ar)1~bmZIO#&+op`w zOT1|(Z)mJVDN}V-7DOJSr_NYARlVK5mw~xpV7hvr(Pw4$OsH1iwsL|k$zf0X2}eHVA>IP(m^s)tJiS{r zuat>~+NOuU^jn8}y|^r@SAug}_#o#uOZbJnLj0=ZKkNSs@3X89yu*QyU~}RZ>~{Pr zm(6*7Q~KX{Q|eOU1I7;V8w{QR*qOn9#Ktm!(}6ECEIh-%sa$wh-Z&h6AAIwuOAREf zD1jpie$tH#;*yajyjZieg%6s^0ATm}62lsn{S!+<6R@s2sW_@XY6VXs8>|~_z`D?Y zUcYh7{HR-29oG#@nkKq>m{y}?NyqKPY1K|!{RsMm^J16!=}Iw{c_E4g;YM{|z4_J{ z&auRhYi|t$u=URmNlN#HOSOv(sSu<-F}8d6j=LK4{8fo-XdI-O>P)L*6a;2^&?9xy zraD*yqeH#Z>&#UEOM)Ybyxjbm!V0$q+A_=m0HwAq(n0}c*Ka{tSi?&>NOF014ONZ9 z6lJQtD=*UfgA1DZEuL=ZkQn-t?SM__-2nK>&i>6|I63Yk1ad&LZVB$Pw7v+$vAmPB;j`1@noh##v36*0EeL zU&@nYYzBh@;3R=M`q1g{vJjv+1B=uS0BeaGsgZ|e+Mt0P?57qioLzS-eV|5@(*YoL zF0qR%>;OJTSo#u*SAIflC5sj6UHLR8A^LG7Dtc@F9)GV)(pz9q*A(CH@yHJpRX;`%x?sCGH75ET#5A%@a!f z3$oL~hs6&X{bKxHy`qyxL*sp4MMg)k{%B0y6=A{tF&L%xKg7Kw`I;?BgVO@vy`NDFJj{SDMjqb8`XzX`UH%>*HlXuuUllugdjEO>br-*lXhv^b<|~UhXSN+ z;Zuxtx#Epv`*E4O%EIXttUeKg)UB}5$0RLd7#?4|wB$&*!P&dj3liZH58@GX-=-Wr zY3gtc%z&ipF+A%8X^9=~R1bi_g2*E5nbfGQ0Q!3CN9eBcc9&kpndaBbnnhFVot$Cn z0nQB!wof$lv#=sG;jLMomb)PuRyU5++Tpg*q1OrbCc9+S!`)aEol1JfR@D}`tqc4x z*bM@!>XX_@5p#HWR6V!Kl)N{rM0oD_nZpJ7S^fU{Fp#n~JWjng{TDh!b;FU%UI%BD zX$Tw@bl1ci6J4IVKr?{;R86Zo#=U1B@G*NE9-W4X%K|PhIby2HX9>6w+tXeMJPyk} ziCh2CP}dR+(o1e>tOo(yn~nRG*eJX7l5gFUUk{hP+m_gUg4f4VsR+| z_qJb*({XO5oYj=o8*Wdt_~DExf6YJvMHAjtOrL;T(#hi6Iledy*~&JCh|5oc%7t)C zLy%y!Sxh*|iWmEjO<@bgy)9{S`ITW+HlzOqqIZ+<(B;ft4{=dIB6kqJIvU)?s~K?r zd3{#-9DY*93N7}KY`>iX(z-l>sfsxPu$`Tvqk!DX@V5UOUQ8oFdlhrDkbN9|sk;U=r znR1+)D{WI0EQcS2A;tl>tyeBH#TBrct710w!|f?f$T9CFA_U3vF?cQE9ecwGlFJ)@ zb!wH6V)TLYYyxXJ>IO|3b^NNZGE1rI(T@4ST&FaDwrnVSE?EztwCAa4aGBkIQq|a= zJ(;qcWB{PzWE?QN%(Z1hhPGToW!or1K2}n#y%5jU*l&BL+wwW!URqg^%)CmL)-Ida& z@_e%QwdA3;lJS(uVl^G-6jX+=dN6lRDc)#YgBzpT#+IgbS4r5dQn+=O?TnFg%yIg4 zbVDl9u^U0J*oIrDAl29+v!*Q9R%qQ5NHSNp)404o%13+$@BG1x-_N#)|FB3s*Nk5^ zjbHKL7{ARF=Oe;89oxm1#JB)_I{TRJ`svOW_9)DLi1*vUWv3@sp8WAF$M_9g`=2o^ z+d4>#xJaG4xpLu|j&%U%>^Gpi)Vz$3OZpQJC-K`{zHCb^ezJV&*kw6&Q#hk`0*ilW zSVJ2aH5mK*R?#zssUs{+iYW&>3r9C+gw`(Oz$`GZ3Iij3aznhmVCllx02Zb-vlAJM&>c4kvRkqA@F;wSQ;+R&SY1m#$Do;>l#7T^?oo zsIY#pBgj#(L4YvSCk33MWEk`117!e#5Cnq&X6j!oXKbe`xUr_%e!|odm^%f1MmJm5 zUN7pyVJJYHZNL#PEN%mw4rx2iv6aCGNmDdm3xQw+4>SBgAao-aqy}-PB-9mb&V9Y& z0pxBN3WE;`oS}$p!3Z0pS52jD6QG!0S##s(33$4o;MR|%?OY72NmCejg7rMaorRTK zpo}vTAjD00JFd#PE~W>NqjmqP%WCc=R*Kr)`^JK>-H!^3nJ-(Y7wq|O_;>L|W0B&$ zbcQj-7)(FT(uf$u=!;zp*2ZmQ30^i)W1E|dexr2Gb_Poo*ILV{DJdv+A+bUeFy*(M zvXzU4X8sDnJI<;p!@2s-*NT|-+_~Bts1OH88(&ZOPe=5mgLG06Cs8zojipz0lo!k< zigdX}s-juAb*!LGJj5$lE=`gBC=wxVJ++2M8&Sq8T58LMNdqGMj8`O29gh@s#Ug4D zNZL$d(4$8vO95L!4IAXNPEc7~=OFt}5bOMBUiPO{iQf#yYYlS&0md9h}B3m>G& z0&w{7iwtYm8`uSDA8WP^%O-TY2PVBu2kZbRnd8_BI(8jk!+fN(9hmmr)pp%6u_ zvfy~%qJuF`vjS*{?UOC@x>@IZwvBU45oEe&I*<-WwN90<4orey9m_w2K6myHE0o(`eejwE*n+gT_wNu}ly!QjAX+9uj ziZeMwgqg4;M{d#M%(r{)c7_h+3h7zhBLEl@UUf{|#J>LXEI9(P8;>AZpDZ0lTL=)= zj68K)^I+B{1wn;#boctKjgTc10dni+=;HhlbxHs@tDyD69+|vZq#;hi$-`{6Ds4CM zkiZ4q_sOK1n8`;=yIybm%9Jhvn9tW|20JRJ2xZQqxjJ;^ z?9h7NX#JAsGynlNRn8X+6_U>p)XS9{42nFRjnB|HOY5 z7;%Sshx)9!@0|L6a(bU<1G!gxFE8&|-ZLWdUfJK9zY_HIbv+D=_oLJmy!oonBmb+0 z@6yOdy${pBt&Vvu`XBD!VmVKOQvTHXC+1hu`){5`Cf1RMpXT0s6&@YY_eT>iToD#l zKSop8>L23i>KEtJpGXT^@kg-ctzJLAzh3Kb9oDt$u&%@UjA89wyV6x*tr9`fD-?H1 zuh8~acll?I^nz+;yF~g9{*@JszmrAtx6)q_h^21S`|kb!BmG#s3hAdf8LR}!0suCZNT8frU{2Gxb1_Oh1+sCj_e@ZHT$Mh7(g-} zZ9BGL+X6P04BMM_Y}sww`;K*7VA|WWY+H7LJ^aL}J>5RuGa0s>n|N#Er#Wt%THWmq z$o>!T)_O2b%piaRXl;i(mVM*y36|dfN~ogY#9mU(2K@9G76t?$;?R~@PYY70UfL$3h1yasnxKb9qL%1>})uky@DFrG39TCZ?VY>RV z<=o{6-S_;s@xFNdEzYx24$cY7@{0UxJ(1OukGC?~R=f`V6qM|^S1WKU=vTSt_)K}T z*Ta!1uc%6#NGp-St!T<;x-EU4Kx(nnJ%N`!MZBi;Zsz;K+6h3=ehPkE-1+x^p+l~g zK&T--|Lm1e7UFHC9|*Q|BP-rWw%<(y=_sn@r&mJRYW#KykXv5Ig7w{3Lj8Mv)=tx7 z5QrpfHKPQg2A4s4xVRf@0&wu!8UEV9kr6972YE>*;K_dd_@ z0%3}stGxV5sO}<7*86$qka zroVwO_$(ODCa_#Q&@%s=e`Qz^uc-9u4xZB6nUXshs(3teM-L!G{6tjU%cQNxCD%L~ zy}Nwd0HA<#dp(`8;nhd$~;tv>pfcEFzxQhfdlO#o7~;D_0g>Z4n_%+B7ze0Wvr0C3~0s7bt_ z=8TH`wv9V$cj}rw$L|dLb;RKu_f=k9w9I3=KrH2K!!4@nC{}!{BeJnDy9I8!Wty4} zUbmOix1$W%qVBRR_*QJfEv5bDN>2U$-Q^b*Yq#AKI3%iK%NjVbAM>?xAU=UjjsLJL zG#}ts^;&T5lV&wl^$}qmXRqT+qEM{h(^+%puOC=kKi&E74Xd|mTMN=K&RC~$&hCR@ zTvVsPCH>_`OZYuFT>J)Y`O7#n=EQ%(@UMmC!nqYHNKu#Sx6>VF_x>^7I)>})!wC|B zV?^PcM1V^SNigA@3zuB3j=}|Fbw3@j#9tiJWqKNp&+6SD%hXFvTxZ1eNCJ;xn@wBw^g4Z2|l3ZE#I2CVZC$k&t|OGr-NFRUeR_>7<6#k7FD{| zl%-6od_`HPm%TheecdQ=a{KI+P^OrQMQ(GGpQP1+x;?C zad}2SnWqEiT3=P^@lucJ$+~Zu=_TG( zULZTFVkh-HN#Y7SX+T}^Y?rh`Dy>j<7m2EQ+0^-4sB9{~GOPyx1$heuLQjMElrDME zJmxC~qlE_q+*=4>2gIgBQL?;DubRYgBDKNhE05 z&zj>UfsHX@u@@c;V>6&P;f3<+^}nzGL+a0;csTwD_@TRC=^ktAFUIWZmtFS;{1cn9 zxvPU71J7T{@0`M$6+$nT*OthOQ|0vT+JfSluXn<`O0EoR$_&`xG~3OtL%;;soUp-e zB@5WBPO}rRIN*fUYI&oB%j`S?Y%ZJGN-AARH^Oxa@?@tCOyCGO0qhPZF0k5dr&cp8 z6u2B#SnLk7o!sE&)?P1X7>)+&lx8G8>|-h;ouX%=5AyMOzwf$%yt)k=KdWf z*iLb3J8S52n#mTYIKkTs1)sX-;4nKaRts5UfxU1k-sV1JwZlDGnBo30!D)oWOwJLu zPrh}C@55zH%8xkP@oEmg(9n!ubwq69Q|u6Xct=(5{Plg-^-bxQ6qfTf{!NQtpswIO zHZGi1sNVpVs&N&m2;wnZU@+l>1~>o?Gw>gLIi7h2SGui_foqaeyJv;G%H*`7l2=EUxftaM^00 zlRZPO`#B0V%3Sv_1^KMQcqek zrqcG>9;`fuppDWbEgK4M2zlQtp;p8Vl2`F#<=vse;D%(7s5vf~Ts|Q2)yJ%)9B`{Z zYOBbc6_$bkgL+z->-3Zd*On;hTpqu%2LQW%x+X1R3l|hurEjW4&E$P6zj?hRW9!x$ z07mVo2e4I71F;r;z(^SPu>^qCGP>OUS3CA=E5U18zBSt0!!0jSK5t0ZB##%itsM$lMW|B*ewj$wGHGj6`XgiQI9|8iQ-RzBoFa)0l03Z;8 z(1sD0Ju+8Uq#;h@Ky-81soiAVtZVGzIgOg08GhvM-R7QL|oMQnG9J zR@7PpV7*A6`4WYb#9(6cwk%}*?7HjuqxHhjBLH)4nR2d3%%bCGT=_&n7+#p8_x(Au zX+hfK%}^ch6<>Ed%6-ro`bF<%+b!Dtf#Vz?mlyAZzdFaL)sTDR!WV*^|HV z!LYm;f9rnKm#WqNjo1Hp_?h&p)`7s9tn?;|{8@i&Lvmn|cKPAx*ZlDtOXSANC~u4q zM=XE0u3i6r;l|31b_#RsVR^1c?XCHng1<BPybG=tt;|=?{MN>+0ECF@2}T7T!(cX)^%9duEV+x e>pHA2&Hn@b*!qfc?Dwhw0000fCeR^LjWu1?9Q-zJG$7Pv%c<1#VexS#DWwS#DWw zS#AYxSr;u!`YVxioiyVOpPDZ2mi`XZ%t${s=gE@#d7q!mmYxrbcG8SqbBSdB^=(fu z@LqcMtCNQJk+&$7lKA>cKb@%>my@ew($7qn^gLN5eBhu%CmkGA59z8ln+7CsVrT8Q zw(XjmC+dzW^)=H&H7g)lVis6?)Y8JjmNg$HEoj^uy8cZ4O5uueBB8W+H*LOiN5v8d)Y@F|JJi_Nf`axNA+oOH& z!|Zt_yy)PBaedx8QA z?oBBl55A}!h;uxUv-^1f2vAv-PqywUf|nyUp1(2&9eOIrx45MVnXeyp1TL}_?_}BO zCTd$t#qe+oueLO$E6fgnZubGj5-OEZx1xxB59Ro7i0UN(WX{!0w1}#rjwzt1A01Hp z*!GK*RaAuuUp!UC;Wm&aBR~ZS6$B`|^+_2U;YKQ`Z^Dzz`CN)MO-1c1-_K;M!$sCl zK-qYxHNZG_M@^q$wRHI6Sr2EJ3n0ky03at>&o+fq%^CYt;7z2t#s;d*fOoQv0zgRW zD+ZH?)9NM6)d&-S`*s3>mf+fcY9|Yzf-@DQN&tXEuZ8C3Wp<=75COUv1e%SwNLh)D zqOKvKbU4x8oFQe5BrH6a1AzJY#&w~zr+spRli3%w8ol=13MY9j>DyI(`%yYxn~u@p zDyubZxuEa*txG?EIeO}nt51vuv^)yirfc`QL|C9 zC^fTcCC2LdGZYJ``aF)HM9i#gE*wgl2e8h&0gjl~Tc4(+m2XEX+l1-5bPWJh!&csY zoG{(i68qYtVHdbiSyFre`)zy*q^wV7Isxb6DhtP6c#k_ae2g6e_)@YI{o2njYq|du zWXby9GjrT8$H!Rjvwb01B%fav@DW*GfG;CU4*(VmK7^iZ{@}8Dv4yj^1kN2fFrFhz z`vZCa+6Yc@Y~$=~x*GrvZl7mS?JA4RKGea5{s6;B@__+wS>OILvc`Kfea9XP0JI~H zS?#2J9{Lrt$7!x|8roHi?T=!&51LJwhIJl`m~}+(7Zfv^F}dDV)~L5yioNHfz2+hP z?9~30w-NwmG!yz8V{k%Xc^%X-D>jvmVVOgA0FpUt4#WJjEQ815$x0H7ZL$O9>YT3M z;Zzq6R7O?;STV>pdo4@*BZk?{H2~6W=vQDLe!aT8puoK4Vr2!7&)q|y4M1~edv0M! zgCBz8y2$|4xgvRvi1(r*SnuC!3=Ue3UtA2d(S?!eE1mo*acsHLMOKE!OYgr}%NX;9 zxB1#c=w2M0RCY0E0#DfzQ7!id0s#5=Q|EHyv|dxWoqVRy=VE2GQw9hS0Kn$w z@rToP2wAkrK?cxOf&L0TEqgsf*7sviu1QY?2s|)Z8Lx- zSdqHGv;!7;sfpPnqj$%3r?V`6oTJm;hS<&InVy-lgto55d) z+!y_4WvxU2F#UjEpzjOGQUT_)c?Av(u&bajLj$c@Wj=&Mvjo6^(wPIBw-x}DCKv!? zv;u&Hg`=~w)Pb-Yk)*&8tcPY8Kw>#Mgc?u-2fI48+77K9YP~~+Q?xb^4&_-{N&?4r z5P%UjEmY7MkBxf00dgx~(LrU_K@PRvt^)MGudM8W{52kR{`-R~UBQbX9o#1FBsYA{ zqxK%+ikgAauC&sb2fIJ50T?W5sFlQyH{Vgfmd;|;42QVN;*8zW<`wlNDGCQ(PTUa} zw=?0Slln3?^ff1cpn7s2oN%X7YIk_RwkV6*4}pp7rCl;vh)!k;`T)?#Cp)wAwRJv zR!4wvgeVhte1?GX$T$z+H&Jjk zFDfIcRCoo=V&gcoR!tzM$}Tq!0I1|Uwk61idWz)>~RG?BVCdu_v3qa zt%Do4EVnGTtp80}8aL;1o~*5qz}9m~YdxQwyC+Tk-&`V@_ulsMYu-zL`s}1>{K#9R zzR8Vf{bbHgdb8-v&|OCBFLIrI(!BEhrIe=4a|=T%0q9{nGQ$WBbY>$+6i^!h^|8iw z;85F*IvY-rJ%iB!0?fX&AMBh)xLHrbAhfB{gO z0Vtt19>Z}6#n}sKjpfjywL%TOMQ78&iSF2}GaW(efWzecV-1cdCIG5qtqBGcEGZoa z4y_TVeA@^ob{$}V&TQ8h0khTlcNRMUQ{o2n*h_&C7QGG{sO)O!b)Q&rRM7Wo$g8VZ zROnZCv(^tJ-yK{Lhs#YzU#dqhyV$r{aJ5Eq?T}`*Yi`8W?W>kI+(OE51S_f zxg(D=o<_}a24c*axjf+T_HKR+|A!7wLEz9sUO`(=*ugi|0InWgY4Ee96~4Y_*V%rS zmDWvVH>D2pDtl70y-U0RfIsr>p_kHE+D^Bn#jbo)Gu)f7bN>K9R9E&2gC{Il^03|9 z{=Oy7qI0~HWy;Jw%;L61W(|BNidm{oT)SGnkxy8$P~*K(*q5}I($^f(M#RyBH$5#s znu9AoO_urzo*cyE$4OcGb}$H#gn+Ce_uN2$I7q|>1}lW85pTuX4l5;=~?Q)Nf*WJ#(p zz&j3O&pHQ~i^E(39QK3_3A@=F0eG^P5;P>;$nLGL7$-nDQj$MGR%lpJNxrbA`<|Va zKKn#+bN-4P0PG%8&sI)DYspCRljh4SSJti^!wJ!RZ8E1ob7`M0>`Tsal|@gTrj?g- zJ%%$=2fXX)%mQwGg(kS@DF8oNNY?V5vF}$euiX@ghYAa{-)sYpn?sjnmv^bGjY~(a zKf@#eb@Lg$AgC!Yl@-x(XBL2{_$ngWFLSteWF8#ZH@i7?ceGwA00`AtUo=dH)fMrt zZC+^+xc&@{)KdM6`sqk6zHh1COfFSjo8J7f%8s$55J7>krqj2BA`*p`GG10?*Zr%T z#hBmu4Y!pk7jRoyN&l4F%7JjS2o}*)}%Fj zjMoYJ^JU56kK7L|&WDuce!BBNA#3*Y$kIk)24K$N1DJvU=)?aTS+j+@nM(YOR`$hY zX+gtTTmt8gboQPj3k0+PZw#lXGx3&q>zi@1d7nj|t1KEh=@eWr`T(pNDc)i;d>L7< z%6q@pUz-6?#Tv_I1Qh$As$%lmD4djJ?w9gA(h~gkGxK(NrOUN~_G_cv{l(d-9a(kd zscB;_vV^UkTmG6d-_aZ9TdHftuO__q0idw;)#wl1r?TwK7x1df-LJ~n@hsirCffmE zx+AA4?9ipO`oZy`QUUY)vpCnq{;5`gsyQ2C<3j?IS5iF+>_dF9b%k2n;-lpBv4 z!BAuS!`axLtl74li_#zMWRZ17#dEwd0?t?Po022($^J6TekuzslpO-p;@R<{aC2>Ty^)NH zMN?&GOO8g%^EuNw+&ahGk|W1`SgKi4AikmA7=1_|zlqS0Q}m#3W{Sp9?ku$b|+|7Xa#I{1=^s&>g+N`d13bJD|P$9)vH0X zarylnsk%ocl*~d`Sz+yeU$`>(B04`o^zGcGgG&bwv;eRWMazlp1pvz>X?Upc{$SN& z0_XM3h2>jKRsdkfcdmpOTx5B#c{fmM^9OvIW!Fc81j!NrAsxAjsv@8@STuis&a3(* zy~%(2>OSV^zJoVwHWI}5a9?n%jNWmvvPKk88uS2QG#j;t>LZoGasZ>F)(pU*1&*LO z)E;WIFslLQ2;_hPdYe(Lc9jK#&Zu-4j%_&7nUzMRO%IYIlhs06u_=$$4%6`=U@=2^ zghL}p_U+KaMV20Vs}V-1%|@kOtHSpr{fU9cZl{qHSFt0iet)UzG@) z*+2^?c2iX3aFI3K>S?dzY$qvtM6Khrmj3QOI2ns!Fk|=X*xOTWiL?GC#fl29>PdZU zMh~3mOG`i~BhJWb7f|H{z>=EU!YY5c@bOSl&+t6dyYNR}K?@z5S7_1OxAwP+#S5{wAZ06^&yR1alt5da{+ zKNl|H7Oof1LzSwFeP%5jagh}PqD!XdSdnOiha5p3+d z4=RX5I-J-#Zi4`^k-hp(*6|Av$;w(0u}7_58j2f=)es1FgensUDf>{1eL+RYc6xhD z@!Sg~4UYhX!phiuZlK~M@nU7&p159;^`!Lf>g^T(<$E>KDDQU33(>vRJL?gRa=H9& zOHT$rqQ`r*eOKPN7!+D2W}RfqH$7WCvUu^U@)KD9FL!dg@9XX$5sw&oH2ZAYM}DKd zpA1h02os7~j|IU6#VdO^_HL9^U0S=b{o_mkIog4KFE55C%-&nWuGc$OBtC8I>NLH+FCA zddi*rjU)f944)^WSIHN&Q!mpJ`YHN!m+y;r01~z$UD`j`qb0t~at^#p6qq8}qjSTac+`^KQ(i#>m z=5Ef(v<*4@3I>l+$s|WRnYCO-8AC{(R8^9PdVaBlSJ%p@<>gB9>p58`$&4m$eQ{-0 zr=&TbSI($mlx7!~6mXf99fgu4o6c<$1GMtGWk`vgClHW?F;TKDet}Ac`J#Acy zpE2P#lGE9Z3&7-leDAJxaLaPba^sfemi0Tz(!5M)Iu~x!`7k3-nrbhXNJ-q=US<7z z>GYJQ55oPA4kNSazl2hH<}{A=i`gvdNmGC4oRPf9)YDEqPjlW5J|kCuE))9mQG_{L zf0qdF+qb=?{rA$l*(Xi9{#+*CcEBQTUfj8|v0QcL2nU7cA9RtQ9Oh zu@GB<{FXC!9ct1{b>$ncINg+*Mr~8EE};CNo(*(0d25=B^#s~#HUheyvPwJZYPy7F zQ-J(>TPW&SssLVd<_Bek;o-q^cy|iZHwgrTBp6gY+&ahj$!ti+XZOPHYR>^Z6f#3tW!#l!}J>`E|aukT*xl-%d z3u)a85S_xRk$GNDqnNq=)JiUfaK5zS$1^nUjz3glztfVV1!ntng*KEdIdU#|B>V84 ztdVN#IR7A=J?0!JUhb+OEQ0*PV_P(cy!CcIq4gKcke_U+pkGiGqbCOw(7~9y!29tzDG|2>!v%!GD zM3E6#WFbHx1PCM)2to*vB!W;t3FYu!B<#7ugJ+l>Pt8m%&xbzr?;J$;Q(dLg-7npN zqRp(0mzE$8yo8=UH^j4bsnq_Exgd9tCkS2~kCyJY54}BkbQ-o_*FNYk>#;!kZYE>x zf5n1W#jAy?o`Jai?9HUsEU(AoyQ@1fs;BNCZGyCidH6kx3pr zkMU^Z`HD3Vb19d~>X>0E-AnGxX}6APpWmL@omK;YdwMsRtKd5zJCXuebE>DEGdt6}tnC+TfaJu$ zIs!b?>#PHo=2)a+UC3wISVZs-_9co{!5Vj)Ee8O>0~{zsro~;8M4)FWbxT_o5xgh? zy%?Nzi)h0dxCDP$I9!Ep9UKuJ@c{XOYkCVyQb4yY!tSA+wJ)Ew`}WMXd@K`lPze=F z!7{9_Xjq!H_MaI!y8SCej@Thri5vy|FmiP73X!9`Uy2;vG2sf>u5Vae-`)8QWx2jm zw1bY;Jj-O0hC59W$kAMvF1yAyIVP=a=_phBM~*Da$>oZ& zH9G74L}_0w09qe}jH3-krI^Yc)lJX%$vU_#r?(+%v7)^jQv<5P_hUf$)H~Pmgd%@{ zU)t6BW?R%RGmbP0bGw^B>$`%i>}nkmBmj_5-_k$46Q$`R+F8B=)Nk}h<@T_F;cjkb zmoBPhA=RTMA&FT*n>i?6ZtADOPgVwqAPx%JeUYOO#D0t%okiLgK1Gf^voBAG2Simv+ z)E3@5ngoI^>7Y1&tiVqeNZ=%`Cm13;v5SR;Cn1Oh-blK}BKm3BTL`tr$Z9sH(gwLH z2=l}`W}n!vaAfuOM7p9K01~#-hlbxRrz*d*o6!!pqB};yfwQRWRDk zVv(#9CBA$NQUu4#^-e!o(T=#~lGgdQR+T$akvHF}1@Oo0Tt3l!5yobGetVR2Vjajz+{aGdL;7{>!PIk6ac>$vMKBM>?E7c1gp)V zXt69xj_WsdL!M#&AbreVmV9(zx!E$v=IjrTt~M$f%{>+XaM5Px*SP>-X;Sno@EGG( z%<|o>%jjUhi>Y4Z==GPyR518MD!QwgEo)utUtsV7?6e7>90Tl)3*v4`Cwq99+q+KL zgPl6Nveaxc(H?=Z+;dtl^q1AI?_HW{l{9ZP>AFXiO^PNfDw_BZHR(He`^}Or(ZB+G zc%meC;;c(fon2k}x2Fz=f2gU0@tcz?eH(`Vpr?A6rT!0_XL%f+E6H*=@j0&RN$IaR zgXbIZWtO$Lh;Q_v^mAEeE&yaOh4IhG2_)#;zXq$l_ zg=+xzeh@iwJ_msKWBfc^!C+mKWgz$h%0DKU_uI%~jviv}g}7SVMVCm&Odq)V$<-cZ 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 literal 10454 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 M07*qoM6N<$f`xESS^xk5 diff --git a/tests/ref/render/columns-balanced-leftover-5.png b/tests/ref/render/columns-balanced-leftover-5.png index 53d467aeded901922b063ceeec0ed49542b3e763..ece539a88ab31498c6da3ef2ddebf62ca996d793 100644 GIT binary patch literal 12058 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

X~1w*T$$dB z*giDD){Vf@K+-Ry08QOM1W<=lR!r;+>dwE$xvETw#Ol&CqWt{E;|J~tLwpU_{YF@X z++09a8$-+=N+TcRPTfEMOn;Vypv}v8ScvlnNfL3XQA`JK963p2GuaTwB1AykZQJ<) zc550qdWuKMB`=Jh{f)(dK!e6Fz76K#3 zum-V&5@$6OZ`&X@h)#70^|S~&6=d}nVLUh^O?$23MP4X~2PD}4IeD3h=0Soo0CB45 zSM;*sw<~)`WD_L3bLZeueQGnf9M{dNK{dAMC>l1C%7+W!F5d*D7R?uvtp3J^_e5xw!>owxMU2G29 z(bNIJ1KOS%-hGWTZ56eaSbT)Ay_oWk z^oHgBxaS0+e$kMo+tA`-f_SFh;y|Pg^^DULA6n{r&VkFI{m;Xvrr7ma2-jvAO2LX{${$IglDfwElIP zxiYhGl3OE>em0o-njn2MCuw<)>OZqVUhJ8PRE+6tL-Je61|@qrvgqCNYCZn14H;KezuQ)u_2%zo6&VXv$XBUd#K9k0ko>;3uh zyJq!2)Lko410>*)+AgZ$Xj2)U@^Uh?t4qxS?!DN^fRy9vN7%g;FVh0{mx$j)C{KaD zxuE|*s57G%_{#lM*)cjt2z>NgYO@KxQ`Q-j(gplkHbI+ELCSbl3P6`#`FSVeNp5X@ z0hgr41r?Ndb2DF0i9;Nu)rC@B6Qp|z0KLaYgx{}VS*`=iZ^*>BitiKBn~X$bu{&6j zW$-Mq0Jx78VoDBw8IfHuiko95euOXlvp(OS{<(*)(8B^ZD&~hWjRgY`w<@?f5V(%M z-`VujxbJcsDU41G!Wx7O-~^Epkcw8m(?Ls;WAn|0YArO%R6V|pVk#+^|{?po8?XF zjits}L1}q-4l>ixVmy_?39k9$Ke$VeZG~L{w2m$>S7Y+Wn;8$ zs9K^}9n6eNKAzBZNoQrkh_h4+k-?qe`-}yO@E^jcR9izc4qe9|iMP7R0CG?@@Al=C z1GTW%S<1^p;HU%$aZ)Zp9az$=NZ08uAzZb=UTJ0Mx|!wy#T9J`gVe=tRWY#mZ_4n| zbzozCo*X7^iV#v9NiqcaUO>KFSy;NG68vjK?4KkUrQwb)A+!1GD9-pFJoYuCgXJ&v zDuE9h^Xd(idR;P5TmhfS5gQRq?w{ss)n8~?V1+G$Z@pj`ZZ=7tucse1u! zSCw%0tg0Ya8j0EsGtvuWjR!~RS)VZPY;J%b3oZ8db+rF^m-n?s6Fwk0 z^cR8elhKP*y<27QvPoUuHg<5)px(S;*FT9}JHBT9p1+9O)Xaxh_LvA`7`(o|s%6F9 z4oDpJQ`k8S$}_VXav337;@r$TBj8|NaUABgrC?23Qypzd?!$at)iM@uUanb+@`AD;H3_#3+g3k(|J_c+Y3#=b4&U4@y_PkAxnV zW|mfywbLgklF+VQcs9d9HhjbYO+@|{YmHHHaxW)DKimE`GOcelAGcjZ?dt+8ry|!b zlo}LcwktA73$PtcR!0r>tMo|A?IK3*!B?=?7G;8>k%K;5{@JYRb61&`0CbgCGO6bx z0H)L;xmW_A*D8lA0S3zY!%t(FdyXe+P^hGI;ke|p2|_cYYLX*PR+$mA?a3%}R>o>q zhJco}6H61W(79&!hh2C^9wr$f3WyCtkG?iGn-!Y~uJNjAt}!}656$bq=B1F%`R%;` zQ}}c{&pOe)leU!UCxVmb+Ty!PyMt~b61~m;>~Q5<-|2~7#`ZuC;yR`Q7miMk{MTA* zl&yseQn%Yq`}fZ!m?db)$}K7LECtzZ*fUn9UNPJjp5my@Yb-;-;T?4g&|GqFuQ+); z9Bt$wKQUX!Po|{!8py8^_J*=9wJCd6x}tw1FC&bZD+Te~6udyTPm`jU&te z5dIte-w1aCdg3AdE=r~7_bApX_e3_YP{<;SyY2(&1!hd0C_zT?lvQrOlG@LR*`O-! zz%nBt_h}aLftnjtc3cui+w5@!?!hYY2G;bi-`Plf;kt<{B~H+Cc}~;SmUvHbGg14(|S*L0Oi7vdR!zlJ+M@hxf#=Fmd4@z=nn)Lqwm7!qzEDDZkt%@-!aUdp5ct% zU(>&NEWq$H1qNv=U)RYyXzPLNWFu*bybBywGY0tR-liLeLG-&HiLx8-NAKap3o0jU zC4F4qEKs@ag1%CNRa*7vp?sEan&qN4%A|nKfx%1NmTs}vf#VGQxTN`gu5EQ9im&BZ z2o-kc*rJqy6B-;dtoA0ANyUS9TX{Z&xqH|j=&#asloJhZ4O-d$+POUexInJ^nY!j3 z48z(L9vU4y(Xk@6I}z9;f*+PPqmchlpj*0jKrj0}Y73Q^J{A^?13r3?NMgu`8^lq0 zT}cGOCYO6c_!{J+dlnT zqyKHUyqi(Q$C)?Tdj^gm@B!JRl6!)nIkioIA4??kZ%1d&++) zYJEkAcWvttW2PvgDPD4s#MFsIz`*_wN**9;tLqg_CWu+IV;Tzle3J3b8VJ+#xXaDwru2=AUv0 z(WbV1wdvyewOMS?&DKVUd$Z#()N|4Ta0DNP*D_Be! zCNA))eDZ=R-(vifsxCb_y<7mDwhMbDuo++e%9f}5x^EJq&ib&fzlk;^#ki*Ig@af< zn)Hv5f^WtPGR?JYyF}jJSE%x>I}kTxN*+fUSBtTyF?O@ zmM==SG@ny9M|Y1&(=LViz8^Q4G%5_QU@I|Y_+LKhmrZtstfClFB6goetG%%ldgryz zIAHc;cFT9qi&_x|>hI=tFPvvM<^@Cs@pbq@WJDAYlpvj-D-#40u=4Rw6(EFct^W~M z;b?KjtqMQ}xFCmpQHcT?$DcuxXJEqR;vxx!R4TQVmcUvuEP>`T#LbWIckQ?$!EhK# zR8(=MN~d7^HtqYMGC2V!cS(%Ro&_MR!69I2Pt{5p=R{ zxXx;K{H+QMV2eW9%zlqVD^3%>@@w3+#k#Q|FuEsMia*kka8N9oNzpxq8%0Z~_Yjc*4o=z) zAHKx^pr4U^r%K#&X1JveAV;PZuZ86x!t}YF-svjrs?s#s!A({2>$i{yob;I6sZJ^& z9WzRD-v_lIl_UOr0-cTm`bS2S9#u_~alGjN1!w3cg8w{%(;1)q>-Z}@w=?u2pZ`Vt zvVXi(s0%Z0{lvtP45sZy>GQmq3z!rDoX9V!Go~+yg!~fJf=cz)4Y%3`VwW`7ixUM&VhN1-UI~5L?{8PPn;M(mF z_uZt?pr`ia7t7PxoZ}KA6iRVPRd&{=BPtkqi1gJ5a@w;T&`vT=F%z1=Q|f)(I3Fu* zsVZrG~Y`EGCw6_Qz%PS zaJ^Z)>vAQVNkEt~5C*lP$}8K9>9wOB{baGG@6GfWh z_*|p@RRfXCguI0!0=gaPSfBAxXyKA+wC!dAIf?3)&E6oK?Y(4GibqK@Rya>&Y^^Wi6RE>V=Gm z2-k`0!jS-%JF1z9Gnl&Fl86OGb^k1O2AmV-xgmt06Rn)ln@>+MVTY0{#V5r4YBWT5 zl}e#gxW;ozEzL%jZFoO8x1=%5ff&`kCAf?4895T1GABmLc2Inir?|^UHvSD!RcQyY z1-+r$f5yWxRYX4$5So%wen^fPelX_#4F&MX9yPi|yUCPE0YLXvSw1+{e=DYo3v-Ff zJ+qh~$gpiS^b4Vs*@B@SiQB8o5Hzw*Ade(Od$=)b=@ihL3~$5k`#9vOQhc}pc86G^ z&?7-anXeZnQ-B&S2Uv(iqTuAD`qm8cfWI7t5~;#YL%(CNTu}t={H;9^>%leumbk56 zwBIibJxfQ1g5~4lviD;D?c3IzNW}HcDlnWka3PAZw&%{ag#ttt?Ib2230sg-u7^1< z+se-y@dbgT`82+uoh*SeB_!0!!~CaM$%xIhmXeda#)O;6q}2gblSqiCw{c|Qn7P+c zt@(~qS78v2-tAl0hV4aBlX#+H_U#wdjtfwb7R=*xmlY(|f-!gyY2VPCT#W7-_iP7Z zFa~sUJfK+9q{;4i9Mh6p8sInt6P+lO43*S(YCA9Q#jVXaf>mZ>nutobXY*XCGXwz2 z7E+I8jfb1})o-qBYY(Vhd!?3~aP#WHjIEf&7-TDZBH zX7*R|S_maia!n(p>s&uXXI&4!Er;OUfHho3*VWY6_o~4G``@+MjgE$$mnfFVlc#~2?RPD*(UQ4W)FOf271tLzCYDxh}aQlC{ z@~?qevD-m|Fra%M=aHUnTGz>ov#hLYx(ml1nDh$cDxRaAEjbw~z4;c)@R}E-G$3gkSf^^ zh5Oo46ct4ja6Bg+qzJs~_&{@76COCne@FHFOQ%6?;*H?t+ai|%JhZAUFSNz8w0(bV z7BPMK@qbZq7okPZNO=qOU&;TEit3Q*krWdaM=XpTGcEJ6l({Y@GvW_S=eENAr^ZG! zcSMhGV>b;Grne(*`zRqN&L#t0?h}^pS4Dr0?={`GV9sg%ru#+H-GQnpy^yqM%?qjC zusX|tudeXb`kWXXBwNfL$l|n(7tUC<{K;ExrVVqGFO{07&81z&{SUuxt2TG%%Cx*k zmG*_GX+o5049c&?0V`^xB7<(&QD2qs9*O<_ZBld1=5$-;nU(A1(z?h>;mPqfZz|P$vPba-yKmv5tyckF-=93>Ch}0 zKR?*~<3L0ZmwZZvnV6Dh*ce3>+_9n4hkgk_Y&l1hSxpr}vS>hI9&0LV;?T37(1)$F z3N&mU2!!M^Res}jI5^yHMsw}yUU?iIo*C6O=I(2*Dr-36p=0_v{isARLt(axuTnX^ zN_T9Rrcrvsn!Gh~uDFMN-#x?nd!qMcM+UrB%&jc2r=cO5zcV#2J|DI|+c}Fs9sHr- zgPitZa7yI&@r4!HlgQ)orWcpl1`%2y5a}V7!v15x#BJ*RoocQjVS4_w1Xq?GR~V*H zC5sD``_tx#)7AY1U-pUC@WBw=`GW54%Sp}C(kBqla-j`@mcPJW{iUk}ayR9S(`f!! z$xhI6o%>kte+k=`=6Ml-68az7ZHbEkal=;#e0Nkh&Z;LiG1{rXOCx`~iRR(A`%jpr z`Lticnk+W{?;^7gZ|p*TGsMKx5j5my*P4q?#(lJ9)w;L>WDZt~6YO@VG}b;{X#SctU}=t?D$oL!K#3eYr( zHX#ohf=<6|n@tD}SOEN=BueUsPM-}SL>~koX0QF6S;9jOmM;hyzHk$Qbl z(in=Tp9!Z0T8>YinBOG+f&GCFVMKrdRJG_|b&1z?%1-8T*ueQaBuih!j&$0g(~_Aj zq|#WOXEQH;dQ9Eh-aGKwcJT&9XGF_%C0G(cAsr#a#8-sbfpH7lnLby{pElQ1UEB|= za1>`7i;qG16o~Nr3;WLCfDvGym0f`r%+~(91O*u%&i*x$I$WMUNh&m@k+@$%ZZpPJ zrp({xOUC$|YC1~1X8-!S`!8-98XuD%o-C-{`WBS~wgoLK9EMGg-eh3i>Vb7xN0}IEHeE~;P=i%H9Bu0_9k@cfnr}=0}C{WT# z(JI!;{%uaAMe6^8zuLn3{Fb(rZab6(u!4B5CG5A2M;Z<1vwKb?HqC zB2Cd2QGqpGQiafG27#UiXU{%0y5X85gTT3rk&K3h3B~>vR*@lScmd}| z{A4Wh^-mEZf(cB{3B{VhubJ(O;I4q~#;2;Fu^-h(-NTHZsN>^x=O5+n80TDQ{urTk z>r50n3w3DewD){5W^Nt|%lJ@VgrVF6x%7uZcUwn)O--$5R7@=Z3PYZ7zz_q8qH>*; z6?QC6%9a$U?Pg3wd*jEb>CUW}J?}Xl@zf)mq_#VJnVJMTOi?3sdQL`!5ccTX}f-ErNMC_?!O45F^ZE`_@_%ob%nR>WdEOmYG5_wZ}z+*pwa`xS-JxY z#i=@AZ4`{o7tM(zF=r#J{WU=y7lL3h#^Q$fdk8Zo#nX?b=NAR?9qB*j&Ky#ExdW3P zj55zC&Rgrt!XtPNneG&peHUtXBsuC5XMr2(6jO>?F5U+I#!)4i)l0)GuOd%3mtkA@ z>Q~gmbo*U77tTX|T>fJ(H2LS5ItvGtQkco@k+w@$m!IB%Hi{bE&tFeno&xMMF%>-z zujYlVRF0!!5SRUy>GnI*zs+QR3ho*l2p^f^?F|Cv6(y+sGxw@htMoc`W-oF%D4$3( zulddE&+NIiwYp=OjySTY4^x+Sre^px;#44bGw1#tN1Y*@^_VzuCA^3skgU8bn&CNl zURJ z72hd-6=X|4yyl)YaP18@lSiXB_Waed(Qgf;gXK(6a{bf;PlMTluOT7>X$S_>(HD-M zp6o=qNUA9Eo6gR=0kLmjAF$8G@a6yNQGEXm0c1ta6H^x5@jsv5RTMQ9s^u&~{}0oP BTYdll literal 11596 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 KMNUMnLSTZBanwBk diff --git a/tests/ref/render/columns-balanced-leftover-6.png b/tests/ref/render/columns-balanced-leftover-6.png index fc9a7d7f5401fe5c3ebd5440189f66a28506f838..89ed6067fb97f75495a67c993b850ba3231eb828 100644 GIT binary patch literal 14835 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�Hp^|DIv_udU6Dn|2eGCXuV%b|JsET-XSBROb8XB-s z>W-PYDHU#A4m^FJT&5iC)8`$aYvS-WM3J4gelH>sj@c2{Al&8S@LKX-j6IvIDxUe7 zet(Eu&|7?~^_dKfj)Hr2RksLbw_E$Z@2sfEE#t{fYJLP@vnRGziEG( z-}GHo{jK7^`MGMxG|n3StGZ) zYwJ!6>HmIuJLH?)zoxf+P0krCxYd$zm-0=Y>^;G@yaO+Ad?0>vTzx!GHGg$u#>ej4 zzS3cH{GSE*6cZFL0J Z{4Xt#!y~6{v!wt4002ovPDHLkV1hIgD`Efu literal 14166 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 diff --git a/tests/ref/render/columns-balanced-leftover-7.png b/tests/ref/render/columns-balanced-leftover-7.png index ec07f417e1637dbfd7b4ab41dfb5085388f29ad9..3af188ea830e5392e63af8aafc6c56e73d61a697 100644 GIT binary patch literal 16565 zcmV)FK)=67`qF zo%=iAZR&f09A3G;5?!nBI=t{CSaG;)g0Q`Z)6JL7C%x5yR^E}!(hw1g>^#Lx(zo#^ zkko+uXqcs{9&6)9Tm8~in?f~R$$RJ3T9DX?;Sg3%KV1T6+WjL)N;X@`@%!7yIZKXA z>!_D4ZCq?`*2xT2;EGPaM@z9vux9WI96b4K(w^4`>8s2QTeB_DxTkaL4P5OAW*+a_ zqobxBd-E*c?)U1C0jdd0$&_QXDdy>1Fa*H8%Xb6bL>a*M>-0h$dl8zJCB1N@(dv9V z00USw4K467czvwyvT+r>#xl9#e!__AxZO`auB`1CL}J+;Yp`k@23WjP>05BcA>_8hdtC&J2Rg3A%p)jM<{Adt zM6?L~wze=5qgcX!tuq#_n^W;t86J)0(M7Y5 zW$llJJc<;qvp>U+Jn~iFgtyZ9SR%+MbNuWqPue^cuF&&SH#aj>{zPB@x>fb_ST<%J zR8j$aQUWXUyg^E9u zdWswH&W)XSLO)lf`v_j`Wla{i8{YW%1LKw2@WYNS&Qhm%Dc1pjUpNe_8vr$0~q6ukTjA6jyty!nF*P~TE8G-c!!*I%!SekoomS9&9#|Z_Rkj5^+OF_r$IeJ8#LsZb=Y~Zbyczf z(kfHeb_y=EpzCTC)=u&$-F3|iUCQtS;e82UG9YkfD*{q7VlTa2gk1>cBYlC_00TI; zCj7(lwI1Ms{60u;L?bsmZ!ywQw|>&C~yh1M-3 z`6PT$Qx-?gPFZbwAiOoEY^T{--_O=8zu5txOEi)hU;{8)N7PF4rV7-mt*W(-RJ?Nf zeEGCdheKyVtGBhbZzZdC!8$HdMze9{*t@q^jtjOXYulMzeI?yGZnC*c-}SQ5Z^dj) zNHU7S+ue~rBFs{#$vXM!n;2ExS-pXHt3h9NRljxgU%7kjS%Y0N z2Uz!hn|OnRpNKc6Lk{NE&&IpvI`S@FTHD_?UM})cZ+`;bB{o21o4*||pdcS*0Nki! zEMCJpa^R%IUjh04r5*aF@G|t{Li*g3%KlpEutB7ik3F3iGKoEJwu?E{o7dsRTZ&m; zrDC8%bb+_Ny-Q_%CT_ux;Hm3E*6d%qF?b!09H*0~4#PIc6Tk08pl+NL%?NC<3R@oB#qaC`Zn! zA7NR-4~TbxmMttGNq(x$%`0rll`r_QI`1Qwq-^2K>gxO_&5vtzIvTM;_fnxl9C;ZvKN3nG(a%r)uV_KXwdRv> zk6BL&tIz60iufavS+GGd_Iir>`Hjrt35jFyRlQcgL)3HeXJ0LK9u{9`~fYygO`KihKEAdM%46lhOTJl`_N4yj+(D?e;2w{ zS!X@prFwptxVTK@Bb8k3P&(wn%>ORxxhV;1!rpFZoHn%ATB`Z}q!;fiZ`fuSOCk*n zW0ly8d1|wBcC0KFex#~?mDVF%Qj2*0T#m!4&>%5~Y#ZkIuOvaW26J0oF4b6Nz>%rz zwX>z9#a5b{adQ$BOH>!qS?H!J(YvHQuJh1W;n}WI{U9l-*2(0R9d^t-+>*r}^}4!p zqQ)@bknRCg{TAYY!`c+<6i(<|0M!o%$s%aan6#_i4u^DxK)1B0e5Ca@q)F_VSmD-B zv?E(*b1)i3kCqXeBJ+KJ|uxJim4`=G)5-%Ep<3zUuX$&DV?COVi z@<$Oryxf-MTt=c_3~!E*IM&{!6|9y|*Y{=hu#?`bsSMGQNUgMHF?+Il85q1ZYH^c- zkrl*=Vz`dhC8~{M#7$7R+qMfa%Ooh7 zIoD?CsThR@wCxn^-Ro50-l@$RWs`Nj2-z~4Vdj`L-AWM48mJfPmfsv^&j;@HeQ&tp zV=p9UYcM7w+VS2vp1POU=~*9H0kw>QTZQ7iQU0&@I(=pAqd8{StJw^JdryM$?%$4g za`6-JrZ&RB^7ui}XSzngJHo+ib&I93sw= zm_7N61AzYK;jCm5l#H!yz?(UpQpc4fsJ7M(0i*jO2}*bwxWv-;xAW8bdvX(1JP+d+UZ7w^g>*!Khf3cZrw$2vP<&JWz zlcyfZ^+=wW+Ya_q=TWOF6nAB;!kpGqaG|hHTEiwx)ZmbYtn!4TMG4zYxY7SpXf+_?scY)%wQQnS6A)v4HL5TAZ??bw&1C)>e z8KJKin*;@|vnT+1_xccAXxdmJpM>_S%i@s7Mp|P%5MHi{>Nk(_yd9<}c?STOVuliu z0I=Lr-=!;`s&uKgxNDMBygFfwK0U01<4KFnb+wz0lGQQ6IuvPUxkYyDFNdHS4vCYs zZAjb6mRgsGYypOEl8b&T)|sI$Y!XyZn?ENnwP@%A@>Oz-rM2-k5U(6ecX|~rrHTF4 zF@c?j+yi@gUG{RlFaa*)l{Lr);o+tRKhhF@M;xLjHG)HQe4!w-b_*Th1--9wrZxw6QS)4aqHS?xS+j8GB;lP8g? z^*^Cy!zRh6D%x!*Twt9?#8Y|4r=)ofd85hFTiItLq3)eOqGe%%Ks==?e1x!olz1DD zCoV*ijg#~)SYn8Tm(T2z?@@p25|`m2M<^##B2waPFnmIuASX7VlNmwv4$0&p9hIRi zze9_n<3rN;SzBrF{FLz+?9-7Flte8dL6wcy&z_=zANCXXrP1r~uGPKf5XqdU6$N?4 zEpJ#A83J5eMtv1ri~p^dC`}drN3_hYct$Oz5qT6s=6R%%*pS`)lveP%@;?dE5)$_b zFXjl2-DO`~K`o(M1A^Gp~4&)pnseqMRN-KO- z`x{j%t<0;GX7Qvsh$Fk~X)*My3Wv5l@4$S`AQ0dFR`Uv9jymYCo5{#^aQ;kWu<(uf zFy~DZ8u%Zv^xm{~-#f)#BKQn3hW+4vlz6t0-iS}_{7}kk zd^+;`dLvW$zFE5=rIiG_Gz}$2G~RhJXnV1?!^mhd-Xy|}k)jUeb?##0Z4y(@dTLT# zL<3rCijYHf+lPG&6*RP>GTu@8t!wc53VNa+YV;c4^uN5Xd-$X8n@uezi&Zp)pIm7~ zYYTrw%Zb&?LGkmU=pY+42aVAy+W0)hW(ZxtT-l+UGleppTk8MGgE(f9h2 zWyBx7qXuqDg6g$5tEhf{6Um5Y_a?p1M+&K{WMf98gw|2WcVHfhZB1)Rmg@OXF+7*6 zYYX#4bnmZhwNxZWVv4#oxWRiR3982TYqL(P8Y^%+GUZkkTbi**D^Nf;CqY$m2qB$} zsw?DnC+&bvM_uEkEHo{o3(jmY6ec01%XftO{SW&DARg&J2fLQ_88YpZ9~4b*;_hhk`Q1wNtln^lSs zdj&dAf`TFphpS0Q0Tj_3bR`MO++r;bs30T>Y8aZsStZFNC>l?5#`IP25K@jqA0|Q7 z-~oV$fa10lPe~{MqQ{^?(1eM^pNGQyq&;GX8e>q|Hte=imNi8m{%4R~ARiysOLZ^SzUcPko2{iTDF z^Z$@%vwWL+*L$6QdUOrmTU*5Mf4AZ=aXa4Qlk{fLKU@+NA1Qd7;8N3{ zlmyiRP)U{nrt7UqP|e7p`uE^fgzttoKKIagBNVU;I7{eO4FIe0uuF}UTk(U#gM$Rv zOOza7@h&GfjmW5xpwA@s0jfPPzrPY&Z+?%-r9UeZR6QE-t2Wi;S>=%OJ;o>IrGOZxm( z#JnX8Ua2p;DwG}UX7g+1kKJ8W8@Me17+p;&Z-v^IGtB?hO`8g&&ZA!WMlH|WCRWSi z;6mZJ12zCc4?=(&ZZFTP`h%^3rniMZn`pGjq z2-GhvW3qI{$!kn9C<(sY2NxPN5M>j{^-xdAZYyDhmq^8{2#N$o@_uD~e@onLC4%+G30Wg zSGS_rWJT1fVbuusm&3B2z1D$#iCMj7jm5q@Z(o)LHe~Bb=e{KD^Fks^y47TyfEnib` zDX6OHuIkCm$0Ky!bKduyd(Zpm3LT&G=w8VlG7r9P)WSAMrzC9f4&G%7&pc|NEZRt$ z3XSY!_%gjg5l^}{vc1f3Q>+^sGrpa_s0ixl7hX9e?|<-BMfVPW!pb3jyD+;|>D?Q3qCHcM>2L4!JeQX2(Iu zd5p}%aA@`&X#FSesMlvxp1j$wbDRS6nbntfo&1I9ZQS1;oiyKD<1}oL*@OY_0nSRq z4J6S3!<@L5-cHR!9>32pJK#8{chTLq#CR&hVFC@Ky1@nQJtVzjnZ~hL!}4~Yc;%6+ zV{8kQk-tC4Z?-C@2s+`q81bs2eGT4^E1+u4?X^x^iCzxC0mxx(#^zA0#R_XbB9F1S z32j?^>3%R6gdHO>OFu*Yz+2(3AB{IoQ#h2)zYx8D44BXiWwmxh`k+bgdmISXQ>K00 zc68_=ji0y-_rHd1hr32G^V~Q1RNY>VOe&vw1J$O1TF;vWbsbmy3U-J79=0z+4;%e$ zQyUe=`B#a1f!)M8swcLphvoGo_CDM`{55O`jLj3P4c^B-?PyJ++7ES%4JYcO{wF^D zaBJYz62E321NZzDY--)ET5*4MqGo&xcD(f2UP)J8C2C{%y4|*MRiDrY2Y$1c_)^eZ zM{kmhVUvbvXi`t-0Q)s#v3a$f{f0a{wAl5!uW$7-m?+qGjG%}67Vs+*{hj3qnLvNd zKn?e_jr^~{)$u`k<8sNp(g264ov+#5l%Ap4J{F2XezSUx`Ig>c^jKF{eN{!-Q(q1c z`-lU5)FIL+zLnHOf*h?2s~xP>LCDcDi0&OkliJ~)b+BPH(T9jV7*cC533kvijLH7x z#-)GiT4r@oyNN5W>2RQ7W%Wy8CHbXKicNU%6ELUqfG>k$-K_}fR_AbwTY7Kly`}dS zpOxN7Z9Cov-}30aH~H@uRrc`s32ql%9=+u})YQgN+Re)rOWSS%MTAPnfz9;PfKx2GfK~|W?eo|(Y=0c9;od~=f(xtS zH%)z9 zHiyFq5mX-iCfzO$DNv5MD6>-wrB{cACiq%zWb`t< zT-AtU$u=Jv-CY@%O65YPPBuf|8Hq4&qL*tKH8ZFj=+4~od(4)W^dkvQ#p&{_g-Q`^ zk!&DRLE4q;2hsLnqW+8 zS>fT*b8RL?$gXvTllmpM(+!x9?e}V05X~X|_C+I?`690>y8n^ywe`t<)_(8d?PEASC=s|`rs-qr_Q(N@GaMn0J=R@!CY8282$UYol|5UmdKv%bTjIKrgi~s#^SX{JlegKsd(f-(o3YKwMD)A z)C~3`E{_phg2r;3L zV!GpsN@pljHyXk?wfzM8@*|I?egAEo&1aC}^~>8B*DQ3zgZy6G4DadI45rxJ!zqGu z1odyG*iDB$n4Jf7RiB)JpqI9z9Wzx}Uf`lw)cP<6Ttc<~M)4{ZPOZT z4)WgL#U=4nbCYqXsW6PpgKWj@4K}4;*`HsCpoGG@MH@QZ*_y-1*Q)*W=w*88`mPl= z`lz#4k4wnc(BKl9i^S$Y0`6nl7Owl znalL*l|UZL$RuZ_mPX_98R5W*Fwcr9)Gl5CNpD6fODjsX6w|$_GOCKl8^=OtPIy{! z7NtqQ6L}CXuFHz$e(A=M=!B8jG+Wdu$m<0KWqi|WGb`&9S!5oY+pHZ$NGpb>_6{+j zQsrb>gu)%R#ki*_k}lJm(3*@I=8jD6*My8F!2!5HlPr}J=2&~b+{)n${L&(*`VWer z>VAp{>g*F&4(QPB!t8e4drR;2^acR{2l-E+S3M43#E(8Af~tcGg7uT>jh?{MILCg? z>69b**C~Qhh!c*@G=Cvw6yfQg0EY|t-b7r zEZPsgyL@|~4EKFJ_b!(4pl8enJG{S!EuQfRKlx1ajg7@H{5kR?BT(V8n)^Yng4_TiN2>vitA<%4Q#=I-#ANVfN@t- z_n=|5isQc{#XhL#NdrF{itFe-k<7{kthGrdd5$Tk$))r1CHb;sZBTc^NJ z08h6Ez6^@p(GSK83;M$CLHw=G;TE^_-qQOwM{nj9p9$B|8?0<+y$`}QKFa^NsJAr} zF1n3USQnVNJ3^ttRr-H}?FjAsqLgoErdjQ177WOZ1oOYJZo+qqgzMb@ne`z|-G_?r z7caa1D;>7fKR`u8=lf$8>nlr^F#xy!JM}tFs0agNIe6{6banu+p1R#>e@Y*TnDZsC zGVHYhi)S3p|O?wU|j~!?-#>jyMzVn$nOFYm6tIZML1ctbMKe%&* z$Rn#Ro;W~69f5irxj`9cTP?s?984UBp$lBub1>F|?UN13kvrzziTUiQO|W%P3JjA) zAQ<2F1U#UGe~6dNi6UwN&~!336F9YkyRNhssCV!9KjC|AU?y548Bh~(1Iou@CM<9X zHH;aaZDRn2Bzm$zLC~umvG%L!^*+q78MQ$3%&8YFPb`B3wG@iAbop_!SHIUb<#?y~e#cg}(4OX?>*81H2HUDAzxhbW?>MazD+ zMX%v)U`}2m@*qejAEkmV^PVOS>wYQ+U{ohwp|f~``$e3tZbUx4Er(AD0lah_>WrCj zp=H9SJHqMGq{n?I6)Dcrq`DCFI@=wn&Im+#Qa9LW2T~=H-mFxf+lm_WyV||=Ej#O2 zq;d-~BmhCVvvR16Z0Wrs1if}qjCHmd1K_b5#8m~$f!`1nr1WvuPuos6^?q!>S5H7R z=k#90!ONErLDhdm1U0GpS^K>OWD`N}IpTHs+tZ5Aw4DyqdwUr4|1-T&6l7XL;2liI zF~qpQwIZl$0Hf3O2_mTS71J-F;sJOM!H>`z0OufBKZ^(|k@=4-Wy^&j0 zLY%C4SLyvwK;h8qR0?lg6Tc#Y^00~~@3FK@uf4|YQkFT?#A!a$T2`(>(#ucs--)2& z!VH>!7?-Bm%B(h8@)Zq%Dw1WFt=)G_L6W%q{Z?6#CIGPaKM+x0W?$87lJ!yB zWqMV$;$9!l@LlP7II4cVUL&xUU*Q<3%r@4_H=2=8@3j284S4B%R?#I#quficeWR_r zgf;jXEvzRI|qS)b$1&CLFj~gncjd0gwH@I2)7O*8K`?X0!*o3 zf#N-nC7CRrKNOi?@KMq+eTh`Sm>0b zKp6+)OG-|V8?y1lS4B`pM*eP4C)P8TM9Q)V>TUw)#D-~7dS0-;v|>l(u`kjr7<8jz z?pz*rgx2jkZhBZUC@}3xZH(oJ(uAOwBVJ;;)GI(qwGNF3p-KrZPRzlI$yNg!VLtCO z(OXgUPYA>qC#{d#typb8&EfcS)PZCxXk8Ou`}7hIn$7HvSXXEGLIi~ssWARY++n|f zJ1plmAfMh&(Aoj&m#v-du?En{v4eA9D0ZAr*xM_WUll>=dQ7-f!-RVV3viI52CyKT zY?$y)^_;Y>$(v8R5qY$ObgPTp1e(e2bH^b;x6}Zd01CP_O;1{5A?Rgmnv+CTW3;^+ zV1hHM2KYI0FlO3MHN13-WTR>QGp&1{c8eds7JN1Hvh#i~R$PVO`iA&L&U9{v{Brhpx$U&FR@^apI`9m(%0Q`KJ!{wLLHttY*){y}5< zJ$3rkkJNu`?fr`VE!^{0uzB4fc!+=cmEh~TN!ZccDSU!o`K{)M`GGg%oz=q=wd-); zU%*pC4}L|z^X-eNd&8=KBtQMOYO3U|BtERd^Y2bK_HML2vBLP5O6w;=cpbe(qt0Y9 zn6w7H)(BaX4k{o6)%AM434wA`r`JLes9`vdmxe%2ow&wWx)^c*)Dfzd#=!R|!>H5i zwI;oxlize@fDyW&;jPi@pdJuPb3AwGp+6mB02DVG4O%E@ICsGFc&IhOw>BW}P`ZT9 zr0gLNLzzRPVK+2fSIW$!MkM`7Fc!b`LA-+vegd?*6!YdGF7v1Jp#TQuq<2;uPSNZ<|wzItRi*o;I znJ&ho;3khDz5?OD6W@eySYy|@|E>5#So$6+{x9pY>)#7t%ljHCP(AODSw`2wbD4zO z|8eyOPRIxYVbFDhN++iXXeIkb56`CKCQDwLOCkUg9Wy3h+t%{q0I}aI1pYBP%^?+} z`B7HVqLPI*^=1}${!d+oj_Cn(ePLWw*&B~^NbL02g);C0_ z;;>*_%S)8nN1`Zke>JZZ=t9h*)T1HVZ6Hxzwyd{3wpH{Oc$3QeIA}063LL zcMj~f7~6XXj(b>v`I|&g1BDvZynJGqwEbe6Uh-n2?aYnG_7>1Gt9$95N6>2-&=4$i z>|WQB8p|Gb(7bf(t7KoVMheAH<3Swlst9V)`7%0$PsOpsVzWX-*de!o)_!-}%_6Az zt+qX7+qB_j`Nc{8jUHkWJDkDD#VtvBvw&NZ#3S;sFLE%mTa(kC7KNyR6b3K|palrN;)szt@*lYgo?&{R_IQy1Kgh zC)|6|b^D$~bCTS1Jc*i<7~?zvvbQ5~Kt)k;Ku}~xP(j>)3=TLlD2k$p0^)3gv<_s04T?8WuBHbB!1dRUQ6(xiim^xjMnLWP8^mN>je~$1CBL*_Oa&XmbsC zL#tbs?R8x1D%uauot}F=Yg?ABH3Gc;eeX%YZ&@WBoLKxzAh7FNxA>NYswUKS;XAOc zZEc?qUWRuKwXcMik1W9Bac{g1_)d2`D?ZEF_J`nI^IHyF-pI}_tbgkEE^k;bHuAuR z@51SGqlOiNANxDr&^9=!Slij#JNLg|&gTK^VA+3kbh_as!i$~+b=tjg?muv>Z(A-l zyeIP^F}AnYdgQ-wZR|J*@a`-fTYx)^;ZVE}PW`|Gc29Thz_Y8zs03>P*(Y45+q*t? z-)rq5bcbhsUpiZHUh@$WlxXIcxbL-f-zI~8J=u!T9$M`OKM~&WZnSZ5o;bYW09yF} z5_k{a{?*_mp4*=&yaz>SRd$&K71$(ZhpvM6bxyQVvzh=2YS)cV%0`QH@H3ga4qifH zsPXfVpxk?(4sRO4yYvL%RUw5Q?Na9!mOKH#C$M*k8vqCmPZ$+K)%2RdokAo*&2WPT zvrqdH?@eZ}P7l3}@?zGSom$?B3?ahXyT_5x@g}@BW6@hUFo)I<9J|~i( zJUX$+-|ZiEZWui+$c*G|$`%0Zp$G};Tv<3Pr2v>bG80FF;@1yx1=Rq;I{U0%YvZI8YnL0< z&@r8{muV)Fpc)RL_avy*!Bz-R&<*Q%s*Jb`eKK(z391ZGJGY=MU3sa-R0z!FD-pY^ zLyt}q+oAOyGAJgJpafQmza0XQ;1Vl2prP_@Wp8RL&L~E0)dn+;1hv2%BfX)~t>tW! zl0br@fZ|G%vIr29K`R5DFo4j5^{+spmC}(ZC_#90)b}|EuXLOuSiJih09A+Pp1A>l zMfkCx?UC#ITIpf7Uw;{1Veu^4**cVj1JX*hjTyRncanFra+Q76#ORgqlg#y4 z>o}=uFt1prX&V#1T;O$iG37+%dMz<}*+Y3F?Bo`4dXvb1_vj@$)lvB;$4Y`9Z9xzM!4O(tY1T8e3PpSk zhe8*aEp%qS3~vatqZU-~I)pc|70~`!{Ch993MLomvf&`WH;hdS6QdVgkkL8lP;A8@ z2xB9|_%yUfzKHW9deUI%y#aFo?T3SC5sK_(Abhli!r?GF2R#is8Qmx*+j7>?Vmko&=>~pQdag!U1>LkXH!ojf*rD!tWw)`V{G<@4k5tHlQ)T{RctWWY$G=>D%rTvEOt-vee!;bZn0PL zncz(aUY1r(b@F;z%Z!ty0Kif%(s&31c*J;R^fLA!n-n^!guO8mRH1&6y4elyHY19r z33(hl9bF0n#bR68W-A3`BeAy>Dt{T?(9jwMvp*w2)lb9wxzg2MC`y8op!Hs^)WxYi zh1KKGMK!^X$f=!?Yh?b)JH=^@wVwhYKtZ<_*c#Oa0=)7$=(u6LWV@suP`A3qi+xd= zMn@@_RDvSCjV}B=Bq&D#Ns&R7-)VVSn9-a!P)Heh$a})CU_iBZN?_AvQT{t#KVjXW z7ZlZUGpkafmZnMwl7G{ZnautFVtpng`yGO*HUEm6p;vbrYhLBl=T`B5ugtF=HAO}7 z$GpZn`PJVS7xv%^!$0HotNIiQoThl#&d9#o{|A=IM4?ri9wDZO?af&~wtqvazQcKh zkNhXRsx1^guXy%YpCJ6(?-<}iLB zN$;?pJ;-jxwAt0^1r%8d1BQmPb4>Y83tXR3CdoIMRqbqYeIZG|-fV3) z2tVIuUpx`NZUQ$D_d@7Pk)V*kNBCEsTb{!$jpSBt;k||T>x0*E6Pcg=ki|ZE-8>We zT9~=Ph7*f7m}HwI&Wu0F?EDo5v&C^kxe8wUpBiR9)cZ-_7Q;*EB%JHsN9>0V6Z)ZLv-)nhSssCo?IzfI!iyyR?Rev{**W=%z zMHTbsa|Y@v@U!so{y*qGfC)EN;nHgV083|2H$B$x(NFc7gu-o1%UI&}1tr?HJ+VM9 z5V;B8+ANX_3sc0f_4Xceoc%k70lAC8UbGKtQJYA5Z1965D4~qMueA3Y1for>?;T!i@^u|**<;J?XT#GfwLrc|{8Bt{w1m7d zb-DO@hixPXaB->08`>4d!L$a=D-rJ;cD2vLy|6Hz1l7MnI&LVx zIEShMWJIc0c<8c?JJ*n)`j*Oev=Dohvd)d>-YS|7p_((QQ3IW*Nh5m7IgQ}QVxKr) zI@!rtgN&MNz8_rvjU>i;Q74*LB9fqJ0HTEpj9PRssb>uynS^|q??_P4cneQqd`%=l z>EJh!#V{P2l*7!iVR*Ho$5eU}^;J+8x|#&_xHCs7W%FAWNlvmc8Nov;+4-WyDX6R0 zCRb>Z4KM-T0!~{#w<5`r-Mn*8R%6N1)XS2Ve`0m-Mr<0-gIZXIdKx(fTLm1E!MEhNp;J_K*oSTcu`TBb`LtHAp0(xruY`9VOO>Dn zo)!t=jt1%hIE&;wFdYHjaAagS=xs!YfpfeQ4)HcwxG@+ChtDT3!He5L5d8@UL-pT8hAxW zp;fEz+(HC?k`{pF-Wghi1a&4H8s#uls#(MyM}it*ovM@$ZDzEWwK~c_#G1=SGj&+Wk=;Z^CGEU!-DjUz$nIVThauO29+ zL(DpGNgzbFY8o#cB*Hr_al!jdi#i5(opz)W1{F97YPbiC3YFFCMgt_j8s49<^2}?83ucHQgdtOr^lJN4tu1(Xw%UB7TRoLqp7wI$1@QR!{V! zWi)N7x1BBJq6$Gr>O8e%ICIJjVNb37aOU`>pBxInb&`LR zk2Xt!A8iHKJ~!!|^()I)_cN&od zWzuGk>-b{3w0AjebX4L5QSoQi4cE>|jRX9?coLMFahS3xZ5gY9M!RApC<)U*&OI*S za7(2Mf*<1uLN>^rBeaSmTwzd~McwOZ+NY{sqfXSsv?F{1yzPQgfqJkZ%!U@9(kB!R z(Pj8HWxuD7XF!bfI1*HE1I59rbU$wwRsb-6D6fV-1?^=RSNmXq|2sS}fWYnxql;Vj8dguh7gYp5N&tYIJajC78CMj8 z(<(b3LMNA_TML%zh8;u_)I5AlSCT#DJpkVB94^_$v}Q$nF)$5+Y}0mUGx50>F~KKw zExYiP3I-tEcmyRFz3XreFNY$yN6*R2ZO!a0%@aT7rgm1hfqMQ14wKxm2Qz3hzOF z?Y&}ZYED)qt{D1HysoW$N-llDOQqN4KI%wrP%6u*G-aw(`Gj7d^OSzKq&%th2|n^a zq6f(8&Y@-`u^#2u;flI!P63tot>h2Y%}r(WoGSLS9^CL>3al!sN@{-kV}9*}7p?c2 zUi_GcPb+{jD0%!;8m&-}S**n6f23FebpVS0MK1r2pO#L$TUmNvq%15ONbPM`#>v3? z74U-H&nV-0H3gU~I{;g&{n sE4T38!g~wvt=z(U3-2www{k=IGx#EeKKc2Eq5uE@07*qoM6N<$f`KVlBLDyZ literal 15661 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 TPW6iB00000NkvXXu0mjf)Q(3T diff --git a/tests/ref/render/columns-balanced-lists.png b/tests/ref/render/columns-balanced-lists.png index 41860e7a58fa360dde499dce3d29e2cd78f8cd77..ded32e4fc0dfeb188b80d0ea32333268744a94e9 100644 GIT binary patch literal 4683 zcmV-R6144!P)R6c3mRx>^dmib?LleWB}71 z(P@bAx?bBH8!#E~PSY;(b^tJNp(ENk$YKCd5%Kwk2p|t@=GlcL-fU3Ft5*S_+cWf~ zm=vxxo|hUXzC#T*D71|OP_GJ`P&XG2FT}Gf++S#t-*_SKW_pC-JB7UK1pvwd0=n4R zDVLF)k}!4Up^NK1(-#098ISAI?IX}ju^zC<7}oC0vP#m+4>A=i;^l1w00t#sx>X~^ zNfa6LyhE+nJU;qH7l433=IQ&S9F)bIj_I{Dsv#tZqEqbRuZVZ_F!>W+i)x&%Y}m#D z@tLW2#}0va0Ej#Q7nm*kg?ZO!h}>H7cDyi$-UIQfnPOX6ymbfiKjBs33D-~2^tlti zKR;5CrjN5l_zeKIfNlcMsFIr#Z|n3Cl!%R8tp~(`_hj*=|IS?Ca$?c$TE(QTFEpV*~1XP>I;`RT=WPx|?dqAz3PDZXCtB?QC2Ul7^0006Q z!6-BoI`8Ugx`wbM5YVrVh&Wli7ru@A$SY_)0<3~r1v~;-eYo=!1V{=493n_1@S~1j zKEaKPfL@z;H!?gWg=O)s{J@-Cf4fjULP`*0cdawGk~{$+;4PIiV?9exIYHDZPb+%nR+sgeChVCs#v zf(}&#N66wG%@klh-%pG0u+|Q;kHDZ1g(om5w0w-UBIZc|?42OxiZ8a)VO^79&m$Jh zZbq6DbgE_Xwm+4w?4QBA`DQQY*==l{gr5`oaA06&I~Xni@^6AT!Z|cYhbqVM!PtL| zywHbDCe{fSdvdV1r5IO=!pQ|7lU}3dR@5xrvd7u0%lskGNs3}_iV5I%d&c(SiEEZ? zqg7m`aYMAtsG^gDrCX5E;J7cPiRc> zA!AprLf+929kci2vy8+^O34LK%*!E4*I>q1n(fBdLZ>L?Eg?x)9p;z2HXGn;F{@I* zyU>yFrJ}Udm)Rqd&nxXHh4^w;X{j%>dy%|-R3{>a{K>E1SATw>W(_=M3OE!;5X& zRQ^M(0O)_{IMbWS2Nl-Wea1Gpa>-=@nW zA9w(uHp5GDIoG6uY%_<bG44I6#@0eW1++aGAd@_FN_zcN9MPI=Ee?iW!D&jiB(V3O+A6&v)SR7UtSc&3M1 z)!0A4bWG%+)MXL8Cxs(K9GT^8m}t za(Sa4;tVr|469@b@A*OD_K~!cqX6iXx2(D=m@hS)!TTSiT@-6P7XOCp`7~YWu8Mi5 zw}L~&kHE1v!Hg#+;I6~*Q2;318Vj=zyj>JKcgf}Xmn2!buj*$N@fKf0390!M@PmZc zH4bHe^oid30((C2Rz_>){PB{+#k-+itcW)-0>O-USM(hK0jHV}8q%*00AlO?ogjSR z&7|*mH~)f2Qut&>L#skwt!ha}(PXHL#JU7$wfma;E*!1BA5do_k z>U$Q;i+@daZo$r0?B@E!brE=tm!Q%Q01tTkVOWfiQ*0HSYs%C`Y$(3%e}*in8mt{Wwzx<09h< zaXU{A??WBjrqmx46a=^9bE3@Y!Vdl72ibCX zLAQ!C9jbE(?diNmMuIO{Sa3cEz<^y$eZ5XPPcH?4CpFZ$P_ri7h-Kz2aCCT#r^ZZQ zrW{_lIEK0ivOGd&DlX&*eZrCvdAT0IOCR#ci)2Gqf>T{eedF?bf)e`b&I{TttXmNGZ2_h z_HJ`11W<1Q-yC0t7>S`x@ySBB%BDLtw)J%9nZ-@%bl;^-ux}UJqbQ|x>Q0SYO0iuV zfNZ~UyE4}zj3@Yxdb}96W95-^tU5yivA8Lna!Qh@)#h0^9wst7EL!Z#(j7(tL?7g@aN#_Ns_XPXql|Z}5 zGfORX9H_0*hhGBLR@V_`JTqnSmKhB#@CuLcUmwCX?~WGJ!L?Hs9^xO{8Vmuj55UNa zPkbAaQ9eSUK=bv?zx$|qVs(}*UWS!$fwxx+iMH69)6IxrZp8jv)|yco6+pfv2zle{ z1(*6Wpj_{womf~pHJy{Q+tRc(W0hw zzW=sz?xO+Vv`qd3O5U-xVY{zMY z#6aa80L>PrCTfgYSXEcB5!r^&tCOrR4Q26;cy(bu7fMl^v#k?*r)a_G+a>}h%^kY0 zumJ!zfZ>No+A|S_+;IkORBRG=pF$W_7H_N1B<8c43tHz-b2GbPRDz7Fm=oLNU=t*T zfyq4}6~eGC4+a%!9#E^}ZO46ANnu&M&%>na)LOu;<}fM^K;XbJ9J7*p=kA20Fi>a) z;v`h2^LYC>Ho4-Tgpzx=dm<^k*pq|4KILrAG>IGo-*d0=KsTa4QlrP%s!J^e=sX+D z{58gM;b&BqhS?vth3`*cn;U(`UrfDX&g&~-V4<7gEc_; zp=`#{a2Iigzuy?Fyj(1*j}#6=q&StmN| z4Y+`l&LGj>_Bm`4qS0(ZzMA2!-Fwxu8D&i7_lcFLz>6`B1EU=kT}X zWlI;{%yROI#-;0&VEK3#I*u+Q??OlRO7JG`n_svG6Y*J|j4Ykk+Y=#X zvyld%jgc@G+%BC)naa5<>h|pQe3j=ng^tu0il=l~2__^AQ&Z=l!$di?7&Vc3*#KVB z3*LJ0rBgb6nMsn%b?&T$2ob7NS`<&|)YH`%XpVXU5P(N9Ey#XIYXtCw+E^a;p;U9~ zGs)##uknyaKt5wo@s!T9=p9THMpuDDgpeLLfS4lx5rCH-K9rIAp;UQvD`H;8QCSjg z;r%quqNjAUC%TBZ*gLXQ7|fVF!%wji?}=>ya6*^}-Et5BAaW%1o_J4s=P#WR(Ohkz zdV``d67B`jJ(BRmO!;{UZ=ETuQ+WS;`W6tWiW#B}eCZg8J1%wd{=4?xv59N-ZFYk7 zbBe}D+Rb6hU|kg5^#uX3F;H!6T2(d*w3)xlpWX~<(~< zSPdLv1~YQZFufkf-vL1UuIC@8bfyw)KThf7ok~d_R>WIsh!WCkHQ+l5uR9$@de{yC zusQnXN8ELR*E77*~jt!+ObqEWyX$l-mx3;8DFYs9lVaj}(diF+>C z2mt-vHD|DEu0bCFR75UMun84Lu+L61hX_Vhn3czyU<|LgA`Mhs#U_{~!#1nu6aZB5 zsqWYeNhx4Z6DNmy)Ig?53R@&X2!aT+@_28>LZd0b3zYEbec1B`$NC<)0zj$gW=`X! z_oaZRZrCBYcU)<}X6$c;@V(3IB6tz6AAX>|?s|Col#U^vYUke3ygv{Cbd#v8lMXl& zBm%sBf-Y7bNP6;HyVXUG<<02H`qR&8$#QsMZ{c-v=#jZ!P3K{M@f~01>4Hr;0J?5Q z=hp9LOdm`JaE)qXS-82zPmOinc1F`7Nt|@K^;V-CUfv}~^BaOd7uSJ`ZP}AXVLk{U z)&t0K^=r@AUL`37aMTmEqbVvqSB-jz37sc>a&X&r^cYVLZ~qxzodCXNpesLNPb1TS z5+}TQjs+kv(uM1Fv^)87EAU!7<#4IV#a`9Bm0PNdPK8!UTqf0&j}^+NHYKl;SIPVT zn77gsE07chRv_-x%HPsa{#21NN?s+el2;ieuaZ~EtBjIY$@_QA{{r`Epp!w7xZ3~# N002ovPDHLkV1gj#0;d1~ literal 4565 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 P>o%ni>T)%*=3oB{nw#e7 literal 6964 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 diff --git a/tests/ref/render/columns-balanced-tables.png b/tests/ref/render/columns-balanced-tables.png index 8e49fa597b0788eb2b05cea04e9d8e7102cd1887..703151b506b72ca9795064a53aff1ae9a5dfbe21 100644 GIT binary patch delta 1658 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 delta 10113 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 W|Fv2Y5^z+V~ED06=!bg_5K zWErrjGDcb+*k<5g>ixV&)RmYipFCisar%jA4P} zWKUUUO+6NSBQpr2qD00000NkvXXu0mjfXEQG@ diff --git a/tests/ref/render/columns-separator-content.png b/tests/ref/render/columns-separator-content.png new file mode 100644 index 0000000000000000000000000000000000000000..74d285d812710e7288e438fe4afe325b7d811679 GIT binary patch literal 7549 zcma)BMNk|HkewMIxWnM??ry=|VQ?5EK(GW2Zo%E%o#2FE!5LhKKybGp;o~lw+N!

wzr6gL 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 Date: Mon, 31 Aug 2026 18:39:22 +0000 Subject: [PATCH 082/105] Add flags `--all-targets` and `--all-features` to minimum Rust version check (#8804) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25ab4d3b6692..1cae8a08de83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: dtolnay/rust-toolchain@87eb139fed4b08a67bd1fa429a21d1f5d523e03e # 1.92.0 - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - - run: cargo check --workspace + - run: cargo check --workspace --all-targets --all-features fuzz: name: Check fuzzers From c0b6245e4c05b03c4c0f6ae51a32b73bd5838bda Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Mon, 31 Aug 2026 19:25:50 +0000 Subject: [PATCH 083/105] Set rules for default document format options (#8496) Co-authored-by: Laurenz --- .github/workflows/ci.yml | 6 +- Cargo.lock | 11 + crates/typst-bundle/src/export.rs | 43 +- crates/typst-bundle/src/format.rs | 245 +++++ crates/typst-bundle/src/introspect.rs | 3 +- crates/typst-bundle/src/lib.rs | 16 +- crates/typst-bundle/src/link.rs | 6 +- crates/typst-cli/src/args.rs | 40 +- crates/typst-cli/src/compile.rs | 304 +++--- crates/typst-cli/src/eval.rs | 6 +- crates/typst-cli/src/query.rs | 6 +- crates/typst-cli/src/world.rs | 11 +- crates/typst-cli/tests/smoke.rs | 48 + crates/typst-html/src/document.rs | 9 +- crates/typst-html/src/dom.rs | 21 +- crates/typst-html/src/encode.rs | 27 +- crates/typst-html/src/format.rs | 282 ++++++ crates/typst-html/src/lib.rs | 119 +-- crates/typst-ide/Cargo.toml | 4 + crates/typst-ide/src/tests.rs | 10 +- crates/typst-layout/src/document.rs | 19 +- crates/typst-layout/src/flow/compose.rs | 2 +- crates/typst-layout/src/flow/mod.rs | 5 +- crates/typst-layout/src/lists.rs | 5 +- crates/typst-layout/src/pages/mod.rs | 8 +- crates/typst-layout/src/pages/run.rs | 3 +- crates/typst-layout/src/rules.rs | 5 +- crates/typst-library/src/diag.rs | 122 ++- crates/typst-library/src/format.rs | 429 ++++++++ .../src/foundations/content/mod.rs | 5 +- .../typst-library/src/foundations/datetime.rs | 2 +- crates/typst-library/src/foundations/scope.rs | 36 + .../typst-library/src/foundations/styles.rs | 11 +- .../typst-library/src/foundations/target.rs | 6 +- .../typst-library/src/layout/grid/resolve.rs | 2 +- crates/typst-library/src/layout/page.rs | 108 +- crates/typst-library/src/lib.rs | 36 +- .../typst-library/src/model/accessibility.rs | 149 +++ crates/typst-library/src/model/asset.rs | 86 -- crates/typst-library/src/model/document.rs | 38 +- crates/typst-library/src/model/link.rs | 2 +- crates/typst-library/src/model/mod.rs | 6 +- crates/typst-library/src/model/outline.rs | 2 +- crates/typst-library/src/model/table.rs | 46 +- crates/typst-library/src/pdf/accessibility.rs | 371 ------- crates/typst-library/src/pdf/attach.rs | 86 -- crates/typst-library/src/pdf/mod.rs | 24 - crates/typst-library/src/routines.rs | 10 +- .../typst-library/src/visualize/image/mod.rs | 2 +- crates/typst-macros/src/func.rs | 2 +- crates/typst-macros/src/scope.rs | 59 +- crates/typst-macros/src/util.rs | 12 +- crates/typst-pdf/src/attach.rs | 3 +- crates/typst-pdf/src/convert.rs | 100 +- crates/typst-pdf/src/format.rs | 953 ++++++++++++++++++ crates/typst-pdf/src/lib.rs | 338 ++----- crates/typst-pdf/src/link.rs | 6 +- crates/typst-pdf/src/metadata.rs | 2 +- crates/typst-pdf/src/outline.rs | 2 +- crates/typst-pdf/src/tags/context/table.rs | 3 +- crates/typst-pdf/src/tags/groups.rs | 6 +- crates/typst-pdf/src/tags/mod.rs | 57 +- crates/typst-pdf/src/tags/resolve/mod.rs | 13 +- crates/typst-pdf/src/tags/tree/build.rs | 26 +- crates/typst-pdf/src/tags/tree/mod.rs | 7 +- crates/typst-pdf/src/tags/tree/text.rs | 9 +- crates/typst-pdf/src/tags/util/mod.rs | 2 +- crates/typst-realize/src/lib.rs | 35 +- crates/typst-render/Cargo.toml | 1 + crates/typst-render/src/format.rs | 118 +++ crates/typst-render/src/lib.rs | 39 +- crates/typst-svg/Cargo.toml | 1 + crates/typst-svg/src/format.rs | 105 ++ crates/typst-svg/src/lib.rs | 46 +- crates/typst/Cargo.toml | 7 +- crates/typst/src/lib.rs | 46 +- docs/components/base.typ | 4 + docs/components/category.typ | 268 +++-- docs/components/live.typ | 11 +- docs/content/changelog/0.14.0.typ | 6 +- docs/content/changelog/0.15.0.typ | 10 +- docs/content/guides/accessibility.typ | 2 +- docs/content/reference/export/bundle.typ | 66 -- docs/content/reference/export/html.typ | 77 -- docs/content/reference/export/index.typ | 9 - docs/content/reference/export/pdf.typ | 109 -- docs/content/reference/export/png.typ | 42 - docs/content/reference/export/svg.typ | 31 - docs/content/reference/index.typ | 12 +- docs/content/reference/library/format.typ | 33 + .../content/reference/library/foundations.typ | 6 +- docs/content/reference/library/index.typ | 1 + docs/content/reference/library/math.typ | 2 +- docs/src/example.rs | 27 +- docs/src/main.rs | 75 +- docs/src/reflect.rs | 23 +- docs/src/world.rs | 10 +- tests/fuzz/Cargo.toml | 1 + tests/fuzz/src/bin/paged.rs | 14 +- tests/fuzz/src/lib.rs | 14 +- tests/src/args.rs | 2 +- tests/src/collect.rs | 15 - tests/src/output.rs | 97 +- tests/src/run.rs | 10 +- tests/src/world.rs | 10 +- tests/suite/format/format.typ | 5 + tests/suite/format/pages.typ | 31 + tests/suite/layout/grid/headers.typ | 3 +- tests/suite/layout/line-numbers.typ | 3 +- tests/suite/model/bibliography.typ | 3 +- tests/suite/model/enum.typ | 3 +- tests/suite/model/figure.typ | 3 +- tests/suite/model/heading.typ | 3 +- tests/suite/model/link.typ | 3 +- tests/suite/model/list.typ | 3 +- tests/suite/model/outline.typ | 3 +- tests/suite/model/par.typ | 6 +- tests/suite/model/quote.typ | 6 +- tests/suite/model/terms.typ | 3 +- tests/suite/model/title.typ | 3 +- tests/suite/pdf/validation.typ | 44 +- tests/suite/pdftags/break.typ | 2 +- tests/suite/pdftags/deco.typ | 39 +- tests/suite/pdftags/disable.typ | 24 +- tests/suite/pdftags/figure.typ | 33 +- tests/suite/pdftags/footnote.typ | 9 +- tests/suite/pdftags/grid.typ | 12 +- tests/suite/pdftags/heading.typ | 18 +- tests/suite/pdftags/lang.typ | 6 +- tests/suite/pdftags/layout.typ | 3 +- tests/suite/pdftags/link.typ | 27 +- tests/suite/pdftags/list.typ | 30 +- tests/suite/pdftags/logical-children.typ | 27 +- tests/suite/pdftags/query.typ | 12 +- tests/suite/pdftags/raw.typ | 6 +- tests/suite/pdftags/table.typ | 42 +- tests/suite/pdftags/text.typ | 3 +- tests/suite/scripting/methods.typ | 2 +- tests/suite/text/lang.typ | 9 +- tests/suite/visualize/color.typ | 3 +- 140 files changed, 4185 insertions(+), 2075 deletions(-) create mode 100644 crates/typst-bundle/src/format.rs create mode 100644 crates/typst-html/src/format.rs create mode 100644 crates/typst-library/src/format.rs create mode 100644 crates/typst-library/src/model/accessibility.rs delete mode 100644 crates/typst-library/src/model/asset.rs delete mode 100644 crates/typst-library/src/pdf/accessibility.rs delete mode 100644 crates/typst-library/src/pdf/attach.rs delete mode 100644 crates/typst-library/src/pdf/mod.rs create mode 100644 crates/typst-pdf/src/format.rs create mode 100644 crates/typst-render/src/format.rs create mode 100644 crates/typst-svg/src/format.rs delete mode 100644 docs/content/reference/export/bundle.typ delete mode 100644 docs/content/reference/export/html.typ delete mode 100644 docs/content/reference/export/index.typ delete mode 100644 docs/content/reference/export/pdf.typ delete mode 100644 docs/content/reference/export/png.typ delete mode 100644 docs/content/reference/export/svg.typ create mode 100644 docs/content/reference/library/format.typ create mode 100644 tests/suite/format/format.typ create mode 100644 tests/suite/format/pages.typ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cae8a08de83..c17a67caec5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,11 @@ jobs: components: clippy, rustfmt - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 - run: cargo clippy --workspace --all-targets --all-features - - run: cargo clippy --workspace --all-targets --no-default-features + # These crates have cargo features, so we check them separately, or else + # feature unification may mask errors. + - run: cargo clippy --package typst-cli --no-default-features + - run: cargo clippy --package typst-kit --no-default-features + - run: cargo clippy --package typst-timing --no-default-features - run: cargo fmt --check --all - run: cargo doc --workspace --no-deps --document-private-items - run: git diff --exit-code diff --git a/Cargo.lock b/Cargo.lock index 73e4db0647cd..13509f0cd006 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3129,12 +3129,16 @@ dependencies = [ "comemo", "ecow", "rustc-hash", + "typst-bundle", "typst-eval", "typst-html", "typst-layout", "typst-library", "typst-macros", + "typst-pdf", "typst-realize", + "typst-render", + "typst-svg", "typst-syntax", "typst-timing", "typst-utils", @@ -3286,6 +3290,7 @@ dependencies = [ "libfuzzer-sys", "typst", "typst-assets", + "typst-bundle", "typst-html", "typst-layout", "typst-pdf", @@ -3327,10 +3332,14 @@ dependencies = [ "serde", "typst", "typst-assets", + "typst-bundle", "typst-dev-assets", "typst-eval", "typst-html", "typst-layout", + "typst-pdf", + "typst-render", + "typst-svg", "typst-utils", "unscanny", ] @@ -3542,6 +3551,7 @@ dependencies = [ "typst-layout", "typst-library", "typst-macros", + "typst-syntax", "typst-timing", "typst-utils", ] @@ -3566,6 +3576,7 @@ dependencies = [ "typst-layout", "typst-library", "typst-macros", + "typst-syntax", "typst-timing", "typst-utils", "xmlwriter", diff --git a/crates/typst-bundle/src/export.rs b/crates/typst-bundle/src/export.rs index 857de0650355..af32467099f0 100644 --- a/crates/typst-bundle/src/export.rs +++ b/crates/typst-bundle/src/export.rs @@ -3,15 +3,18 @@ use ecow::EcoString; use indexmap::IndexMap; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rustc_hash::FxBuildHasher; -use typst_html::{HtmlElement, HtmlOptions}; +use typst_html::{HtmlElement, HtmlFormat, HtmlOptions}; use typst_layout::PagedDocument; -use typst_library::diag::{At, ParallelCollectCombinedResult, SourceResult}; +use typst_library::diag::{ + At, ParallelCollectCombinedWarnedResult, SourceResult, Warned, +}; +use typst_library::format::Complete; use typst_library::foundations::Bytes; use typst_library::introspection::Location; -use typst_library::model::{LateLinkResolver, PagedFormat}; +use typst_library::model::{Document, LateLinkResolver, PagedFormat}; use typst_pdf::PdfOptions; -use typst_render::RenderOptions; -use typst_svg::SvgOptions; +use typst_render::{PngFormat, RenderOptions}; +use typst_svg::{SvgFormat, SvgOptions}; use typst_syntax::{Span, VirtualPath}; use crate::{Bundle, BundleDocument, BundleFile}; @@ -21,7 +24,10 @@ pub type VirtualFs = IndexMap; /// Exports a bundle into a raw virtual file system. #[typst_macros::time(name = "export bundle")] -pub fn export(bundle: &Bundle, options: &BundleOptions) -> SourceResult { +pub fn export( + bundle: &Bundle, + options: &BundleOptions, +) -> Warned> { bundle .files .par_iter() @@ -32,11 +38,11 @@ pub fn export(bundle: &Bundle, options: &BundleOptions) -> SourceResult Ok(bytes.clone()), + BundleFile::Asset(bytes) => Ok(bytes.clone()).into(), }; data.map(|data| (path.clone(), data)) }) - .collect_combined_result() + .collect_combined_warned_result() } /// Settings for bundle export. @@ -57,19 +63,20 @@ fn export_document( doc: &BundleDocument, options: &BundleOptions, link_resolver: Tracked, -) -> SourceResult { +) -> Warned> { match doc { - BundleDocument::Paged(doc, extras) => match extras.format { + BundleDocument::Paged(doc, extras) => match &extras.format { PagedFormat::Pdf => { export_pdf(doc, &options.pdf, &extras.anchors, link_resolver) } - PagedFormat::Png => export_png(doc, &options.png), + PagedFormat::Png => export_png(doc, &options.png).into(), PagedFormat::Svg => { - export_svg(doc, &options.svg, &extras.anchors, link_resolver) + export_svg(doc, &options.svg, &extras.anchors, link_resolver).into() } }, BundleDocument::Html(doc) => { - export_html(doc.root(), &options.html, link_resolver) + let options = options.html.resolve(doc.options().get::()); + export_html(doc.root(), &options, link_resolver).into() } } } @@ -82,7 +89,7 @@ fn export_pdf( options: &PdfOptions, anchors: &[(Location, EcoString)], link_resolver: Tracked, -) -> SourceResult { +) -> Warned> { typst_pdf::pdf_in_bundle(doc, options, anchors, link_resolver).map(Bytes::new) } @@ -90,7 +97,8 @@ fn export_pdf( #[comemo::memoize] #[typst_macros::time(name = "export png")] fn export_png(doc: &PagedDocument, options: &RenderOptions) -> SourceResult { - typst_render::render(&doc.pages()[0], options) + let options = options.resolve(doc.options().get::()); + typst_render::render(&doc.pages()[0], &options) .encode_png() .map(Bytes::new) .map_err(|_| "failed to encode PNG") @@ -116,9 +124,10 @@ fn export_svg( Some((point, name.clone())) }) .collect::>(); + let options = options.resolve(doc.options().get::()); Ok(Bytes::from_string(typst_svg::svg_in_bundle( &doc.pages()[0], - options, + &options, &anchors, link_resolver, ))) @@ -135,7 +144,7 @@ fn export_svg( #[typst_macros::time(name = "export html")] fn export_html( root: &HtmlElement, - options: &HtmlOptions, + options: &HtmlOptions, link_resolver: Tracked, ) -> SourceResult { typst_html::html_in_bundle(root, options, link_resolver).map(Bytes::from_string) diff --git a/crates/typst-bundle/src/format.rs b/crates/typst-bundle/src/format.rs new file mode 100644 index 000000000000..a710e59d1bbc --- /dev/null +++ b/crates/typst-bundle/src/format.rs @@ -0,0 +1,245 @@ +use typst_library::Feature; +use typst_library::diag::{SourceResult, bail}; +use typst_library::engine::Engine; +use typst_library::format::{Format, FormatElement}; +use typst_library::foundations::{ + Args, BundlePath, Bytes, Construct, Content, NativeRuleMap, ShowFn, Str, Target, + cast, elem, scope, +}; +use typst_library::model::DocumentElem; + +/// The format element for registering the bundle format. +pub const FORMAT: Format = Format::new::() + .with_feature(Feature::Bundle) + .with_rules(register); + +/// Registers show rules for bundle specific elements. +pub fn register(rules: &mut NativeRuleMap) { + for target in [Target::Paged, Target::Html] { + rules.replace(target, DOCUMENT_UNSUPPORTED_RULE); + rules.register(target, ASSET_UNSUPPORTED_RULE); + } +} + +/// Typst's bundle export format (experimental). +/// +/// #info[ +/// Bundle export is only available for experimentation behind a feature flag. +/// Do not use this feature for production use cases. In the CLI, you can +/// experiment with it by passing `--features bundle` or setting the +/// `TYPST_FEATURES` environment variable to `bundle`. To use both the +/// `bundle` and the `html` feature at the same time, specify both separated +/// with a comma (i.e. `bundle,html`). In the web app, bundle export is not +/// available at this time. +/// ] +/// +/// With Typst's bundle export, you can emit multiple output files from a single +/// Typst project. Bundle output is useful for creating multi-page websites with +/// HTML export, but it is not limited to HTML export. You can create bundles +/// containing any combination of @html[HTML pages], @pdf[PDFs], +/// @format.png[PNGs], @format.svg[SVGs], and arbitrary @asset[assets]. +/// +/// = Exporting as a bundle +/// == Command Line +/// Pass `--format bundle` to the `compile` or `watch` subcommand. Note that you +/// must also pass `--features bundle` or set `TYPST_FEATURES=bundle` to enable +/// this experimental export target. +/// +/// When using `typst watch`, Typst will launch a live-reloading HTTP server +/// serving your files. You can configure it as follows: +/// +/// - Pass `--port` to change the port. (Defaults to the first free port in the +/// range 3000-3005.) +/// - Pass `--no-reload` to disable injection of a live reload script into HTML +/// pages. (The HTML that is written to disk isn't affected either way.) +/// Non-HTML documents do not support live reload. +/// - Pass `--no-serve` to disable the server altogether. +/// +/// == Web App +/// Not currently available. +/// +/// = Creating files +/// A bundle is a collection of files. Each of these bundle files falls into one +/// of two categories: Document or asset. A @document takes @content[content] +/// that is exported with one of Typst's other export formats. Meanwhile, an +/// @asset takes raw @bytes[byte data] of your choice that will be written to +/// disk as-is. Both elements take the desired output path as their first +/// argument. +/// +/// The example below shows a basic example of how bundle export could be used +/// in practice: +/// +/// ```typ +/// #document("index.html", title: [Home])[ +/// #title() +/// - #link()[Go to blog] +/// ] +/// +/// #document("blog.html", title: [Blog])[ +/// #title() +/// Welcome to my blog! +/// +/// ... +/// +/// This blog also exists as a +/// #link()[single PDF]. +/// ] +/// +/// #document("blog.pdf", title: [Blog])[ +/// ... +/// ] +/// +/// #asset( +/// "favicon.ico", +/// read("images/favicon.ico", encoding: none), +/// ) +/// ``` +/// +/// In the example, we create two HTML documents: A home page and a blog. The +/// home page links to the blog through a label link. Typst's built-in linking +/// mechanism natively supports @link:links-in-bundle-export[cross-document +/// links] and resolves the correct relative paths for you. The bundle also +/// contains a PDF version of the blog, which is linked from the HTML version. +/// In practice, you could now share the content between the HTML and PDF +/// version by storing it in a variable and using it in both. This is omitted +/// here for brevity. Finally, the bundle contains an icon asset for the +/// website. In this case, we're providing the asset's data by reading a file +/// from disk. Alternatively, it's also possible to generate asset data from +/// within Typst (e.g. via a function like @json.encode). +/// +/// Documents and assets are normal elements, so you can use them with Typst's +/// usual scripting, styling, and introspection mechanisms. For more details, +/// refer to the @document and @asset documentation. +/// +/// = Introspection +/// Introspections always observe the full bundle rather than individual +/// documents. For instance, querying for headings will give you all headings in +/// all documents rather than the ones in the current document. Similarly, +/// labels are global to the bundle—you can locate and +/// @link:links-in-bundle-export[link to labels in other documents]. Counters +/// and states are likewise global. #footnote[An exception to this forms the +/// page counter, which is (naturally) per document.] In particular, if you +/// enable things like heading numbering, the numbering will progress +/// consecutively across the full bundle. +/// +/// If you're using bundle export to build one conceptual work that is split up +/// across multiple output files, this is typically what you want. If, however, +/// you're using bundle export to export multiple conceptually separate works, +/// you might want introspections to consider each document in isolation. +/// Currently, you'll have to do this manually (e.g. by resetting counters, +/// adjusting selectors, etc.) We +/// #link("https://github.com/typst/typst/issues/7735#issuecomment-3908841853")[plan +/// to provide more tools] for managing the precise scope of introspection in +/// the future. +#[elem(scope, name = "bundle", since = "unreleased", Construct)] +pub struct BundleFormat {} + +impl Construct for BundleFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +impl FormatElement for BundleFormat { + type Options = (); +} + +#[scope(category = Bundle)] +impl BundleFormat { + #[elem] + type AssetElem; +} + +pub const DOCUMENT_UNSUPPORTED_RULE: ShowFn = |elem, _, _| { + bail!( + elem.span(), + "constructing a document is only supported in the bundle target"; + // TODO: Support for CLI-specific hints would be nice. + hint: "try enabling the bundle target"; + hint: "or use a `set document(..)` rule to configure metadata"; + ) +}; + +/// Adds a custom file to a bundle. +/// +/// This function creates a single file in a @format.bundle[bundle], from +/// @bytes[raw byte data]. Unlike @document[documents], assets will be emitted +/// as-is without undergoing compilation. +/// +/// The `asset` function can be combined with @read to copy a file from the +/// project into the output bundle. The first argument to `asset` defines the +/// output path for the asset in the bundle, while the path passed to `read` +/// defines where in the project to read the data from. +/// +/// ```typ +/// // Copy the file `styles.css` into the bundle. +/// #asset("styles.css", read("styles.css")) +/// ``` +/// +/// That said, `asset` is not tied to `read`. You can also generate bytes +/// directly or use a function like @json.encode to emit serialized data. +/// +/// ```typ +/// // Emits a JSON file with the number +/// // of headings in the document. +/// #context { +/// let headings = query(heading) +/// let meta = ( +/// count: headings.len(), +/// ) +/// asset("meta.json", json.encode(meta)) +/// } +/// +/// #document("doc.pdf")[ +/// = Introduction +/// = Conclusion +/// ] +/// ``` +/// +/// This would emit a `meta.json` file with the following contents into the +/// resulting bundle: +/// +/// ```json +/// { +/// "count": 2 +/// } +/// ``` +/// +/// This function is defined on the @format.bundle element, but is also +/// available directly in the global scope. It may only be used in the +/// @format.bundle[bundle] target. +#[elem(since = "0.15.0", Locatable)] +pub struct AssetElem { + /// The path in the bundle at which the asset will be placed. + /// + /// May contain interior slashes, in which case intermediate directories + /// will be automatically created. + #[required] + pub path: BundlePath, + + /// The raw data that will be written into the file at the specified path. + /// + /// If a string is given, it will be encoded using UTF-8. + #[required] + pub data: AssetData, +} + +pub const ASSET_UNSUPPORTED_RULE: ShowFn = |elem, _, _| { + bail!( + elem.span(), + "assets are only supported in the bundle target"; + // TODO: Support for CLI-specific hints would be nice. + hint: "try enabling the bundle target"; + ) +}; + +/// The raw data for an asset. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct AssetData(pub Bytes); + +cast! { + AssetData, + self => self.0.into_value(), + v: Str => Self(Bytes::from_string(v)), + v: Bytes => Self(v), +} diff --git a/crates/typst-bundle/src/introspect.rs b/crates/typst-bundle/src/introspect.rs index badd455bebd0..729accf00cd2 100644 --- a/crates/typst-bundle/src/introspect.rs +++ b/crates/typst-bundle/src/introspect.rs @@ -13,9 +13,10 @@ use typst_library::introspection::{ DocumentPosition, ElementIntrospector, ElementIntrospectorBuilder, Introspector, Location, }; -use typst_library::model::{AssetElem, DocumentElem, LinkElem, Numbering}; +use typst_library::model::{DocumentElem, LinkElem, Numbering}; use typst_syntax::VirtualPath; +use crate::format::AssetElem; use crate::{BundleDocument, Item}; /// An introspector implementation for bundles. diff --git a/crates/typst-bundle/src/lib.rs b/crates/typst-bundle/src/lib.rs index ce11168754c8..7ee8d44444f3 100644 --- a/crates/typst-bundle/src/lib.rs +++ b/crates/typst-bundle/src/lib.rs @@ -2,12 +2,12 @@ #[path = "export.rs"] mod export_; +mod format; mod introspect; mod link; -use crate::introspect::BundleIntrospector; - pub use self::export_::{BundleOptions, VirtualFs, export}; +pub use self::format::{AssetData, AssetElem, BundleFormat, FORMAT}; use std::collections::hash_map::Entry; use std::sync::Arc; @@ -20,6 +20,7 @@ use typst_html::HtmlDocument; use typst_layout::PagedDocument; use typst_library::diag::{At, CollectCombinedResult, SourceResult, bail, error}; use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{ Bytes, Content, Output, Packed, StyleChain, Target, TargetElem, }; @@ -27,13 +28,15 @@ use typst_library::introspection::{ Introspector, Location, Locator, SplitLocator, Tag, TagElem, }; use typst_library::model::{ - AssetElem, Document, DocumentElem, DocumentFormat, DocumentInfo, PagedFormat, + Document, DocumentElem, DocumentFormat, DocumentInfo, PagedFormat, }; use typst_library::routines::{Arenas, Pair, RealizationKind}; use typst_library::{Feature, Library, World}; use typst_syntax::VirtualPath; use typst_utils::{LazyHash, Protected}; +use crate::introspect::BundleIntrospector; + /// A collection of files resulting from compilation. /// /// In the `bundle` target, Typst can emit multiple documents and assets from a @@ -98,6 +101,13 @@ impl Document for BundleDocument { BundleDocument::Html(doc) => doc.info(), } } + + fn options(&self) -> &DocumentFormatOptions { + match self { + BundleDocument::Paged(doc, _) => doc.options(), + BundleDocument::Html(doc) => doc.options(), + } + } } /// Extra data relevant for exporting a paged document in a bundle. diff --git a/crates/typst-bundle/src/link.rs b/crates/typst-bundle/src/link.rs index 7778340fe09e..a6ef21679285 100644 --- a/crates/typst-bundle/src/link.rs +++ b/crates/typst-bundle/src/link.rs @@ -38,11 +38,11 @@ pub fn create_link_anchors( ) .into_iter(), ), - BundleDocument::Paged(doc, options) => { + BundleDocument::Paged(doc, extras) => { // Mutates the export options so that named destinations // are generated (if supported by the format). - options.anchors = create_paged_link_anchors(doc, targets); - Either::Right(options.anchors.iter().cloned()) + extras.anchors = create_paged_link_anchors(doc, targets); + Either::Right(extras.anchors.iter().cloned()) } } }) diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index a6968e3479c0..c84c47264518 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -331,8 +331,13 @@ pub struct CompileArgs { /// This formats the output in a more human-readable, but less /// space-efficient way. Affects HTML, SVG, and PDF export, but not PNG /// export. - #[arg(long = "pretty")] - pub pretty: bool, + #[arg( + long = "pretty", + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + )] + pub pretty: Option, /// Which pages to export. When unspecified, all pages are exported. /// @@ -356,12 +361,29 @@ pub struct CompileArgs { /// document is written to provide a baseline of accessibility. In some /// circumstances (for example when trying to reduce the size of a document) /// it can be desirable to disable tagged PDF. - #[arg(long = "no-pdf-tags")] + // TODO: Remove deprecated flag in the 0.17 release cycle. + #[arg(long = "no-pdf-tags", hide = true)] pub no_pdf_tags: bool, + /// Enables or disables PDF tagging. + /// + /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF + /// document is written to provide a baseline of accessibility. In some + /// circumstances (for example when trying to reduce the size of a document) + /// it can be desirable to disable PDF tags. + #[arg( + long = "pdf-tagged", + default_missing_value = "true", + num_args = 0..=1, + require_equals = true, + )] + pub pdf_tagged: Option, + /// The PPI (pixels per inch) to use for PNG export. - #[arg(long = "ppi", default_value_t = 144.0)] - pub ppi: f64, + /// + /// [default: 144] + #[arg(long = "ppi")] + pub ppi: Option, /// File path to which a Makefile with the current compilation's /// dependencies will be written. @@ -758,24 +780,24 @@ impl FromStr for Pages { fn from_str(value: &str) -> Result { match value.split('-').map(str::trim).collect::>().as_slice() { - [] | [""] => Err("page export range must not be empty"), + [] | [""] => Err("page range must not be empty"), [single_page] => { let page_number = parse_page_number(single_page)?; Ok(Pages(Some(page_number)..=Some(page_number))) } - ["", ""] => Err("page export range must have start or end"), + ["", ""] => Err("page range must have start or end"), [start, ""] => Ok(Pages(Some(parse_page_number(start)?)..=None)), ["", end] => Ok(Pages(None..=Some(parse_page_number(end)?))), [start, end] => { let start = parse_page_number(start)?; let end = parse_page_number(end)?; if start > end { - Err("page export range must end at a page after the start") + Err("page range must end at a page after the start") } else { Ok(Pages(Some(start)..=Some(end))) } } - [_, _, _, ..] => Err("page export range must have a single hyphen"), + [_, _, _, ..] => Err("page range must have a single hyphen"), } } } diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index be264dcbde4d..64760061e341 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -10,21 +10,22 @@ use typst::diag::{ bail, }; use typst::foundations::{Datetime, Smart}; -use typst::layout::PageRanges; +use typst::layout::{PageRange, PageRanges}; +use typst::model::Document; use typst::syntax::Span; use typst_bundle::{Bundle, BundleOptions, VirtualFs}; -use typst_html::{HtmlDocument, HtmlOptions}; +use typst_html::{HtmlDocument, HtmlFormatOptions, HtmlOptions}; use typst_kit::diagnostics::DiagnosticWorld; use typst_kit::timer::Timer; use typst_layout::{Page, PagedDocument}; -use typst_pdf::{PdfOptions, PdfStandards, Timestamp}; -use typst_render::RenderOptions; -use typst_svg::SvgOptions; +use typst_pdf::{PdfFormatOptions, PdfOptions, PdfStandards, Timestamp}; +use typst_render::{PngFormat, PngFormatOptions, RenderOptions}; +use typst_svg::{SvgFormat, SvgFormatOptions, SvgOptions}; use typst_utils::Scalar; use crate::args::{ - CompileArgs, CompileCommand, DepsFormat, DiagnosticFormat, Input, Output, - OutputFormat, PdfStandard, WatchCommand, + self, CompileArgs, CompileCommand, DepsFormat, DiagnosticFormat, Input, Output, + OutputFormat, WatchCommand, }; use crate::deps::write_deps; use crate::watch::Status; @@ -62,7 +63,7 @@ pub struct CompileConfig { /// The format of the output file. pub output_format: OutputFormat, /// Whether to make the serialized document pretty. - pub pretty: bool, + pub pretty: Option, /// Which pages to export. pub pages: Option, /// The document's creation date formatted as a UNIX timestamp, with UTC suffix. @@ -73,15 +74,15 @@ pub struct CompileConfig { /// compilation. pub open: Option>, /// A list of standards the PDF should conform to. - pub pdf_standards: PdfStandards, + pub pdf_standards: Option, /// Whether to write PDF (accessibility) tags. - pub tagged: bool, + pub tagged: Option, /// A destination to write a list of dependencies to. pub deps: Option, /// The format to use for dependencies. pub deps_format: DepsFormat, /// The PPI (pixels per inch) to use for PNG export. - pub ppi: f64, + pub ppi: Option, /// The export cache for images, used for caching output files in `typst /// watch` sessions with images. pub export_cache: ExportCache, @@ -144,25 +145,46 @@ impl CompileConfig { }); let pages = args.pages.as_ref().map(|export_ranges| { - PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect()) + PageRanges::new( + export_ranges + .iter() + .map(|r| PageRange::new(*r.0.start(), *r.0.end())) + .collect(), + ) }); - let tagged = !args.no_pdf_tags && pages.is_none(); - if output_format == OutputFormat::Pdf && pages.is_some() && !args.no_pdf_tags { + if args.no_pdf_tags { warnings.push( - HintedString::from("using --pages implies --no-pdf-tags").with_hints([ - "the resulting PDF will be inaccessible".into(), - "add --no-pdf-tags to silence this warning".into(), - ]), + "`--no-pdf-tags` is deprecated, use `--pdf-tagged=false` instead".into(), ); } - if !tagged { - const ACCESSIBLE: &[(PdfStandard, &str)] = &[ - (PdfStandard::A_1a, "PDF/A-1a"), - (PdfStandard::A_2a, "PDF/A-2a"), - (PdfStandard::A_3a, "PDF/A-3a"), - (PdfStandard::UA_1, "PDF/UA-1"), + let tagged = if let Some(tagged) = args.pdf_tagged { + if tagged && pages.is_some() { + bail!("cannot enable PDF tags when exporting a page range"); + } + Some(tagged) + } else { + if output_format == OutputFormat::Pdf && pages.is_some() && !args.no_pdf_tags + { + warnings.push( + HintedString::from("using `--pages` implies `--pdf-tagged=false`") + .with_hints([ + "the resulting PDF will be inaccessible".into(), + "add `--pdf-tagged=false` to silence this warning".into(), + ]), + ); + } + + (args.no_pdf_tags || pages.is_some()).then_some(false) + }; + + if tagged == Some(false) { + const ACCESSIBLE: &[(args::PdfStandard, &str)] = &[ + (args::PdfStandard::A_1a, "PDF/A-1a"), + (args::PdfStandard::A_2a, "PDF/A-2a"), + (args::PdfStandard::A_3a, "PDF/A-3a"), + (args::PdfStandard::UA_1, "PDF/UA-1"), ]; for (standard, name) in ACCESSIBLE { @@ -170,18 +192,21 @@ impl CompileConfig { if args.no_pdf_tags { bail!("cannot disable PDF tags when exporting a {name} document"); } else { - bail!( - "cannot disable PDF tags when exporting a {name} document"; - hint: "using --pages implies --no-pdf-tags"; - ); + let mut err = HintedString::from(eco_format!( + "cannot disable PDF tags when exporting a {name} document", + )); + if args.pages.is_some() { + err.hint("using --pages implies --pdf-tagged=false"); + } + return Err(err); } } } } - let pdf_standards = PdfStandards::new( - &args.pdf_standard.iter().copied().map(Into::into).collect::>(), - )?; + let pdf_standards = (!args.pdf_standard.is_empty()) + .then(|| PdfStandards::new(args.pdf_standard.iter().copied())) + .transpose()?; #[cfg(feature = "http-server")] let server = if let Some(command) = watch @@ -325,9 +350,8 @@ fn compile_and_export( ) -> Warned>> { match config.output_format { OutputFormat::Pdf | OutputFormat::Png | OutputFormat::Svg => { - let Warned { output, warnings } = typst::compile::(world); - let result = output.and_then(|document| export_paged(&document, config)); - Warned { output: result, warnings } + typst::compile::(world) + .and_then(|document| export_paged(&document, config)) } OutputFormat::Html => { let Warned { output, warnings } = typst::compile::(world); @@ -337,17 +361,14 @@ fn compile_and_export( warnings, } } - OutputFormat::Bundle => { - let Warned { output, warnings } = typst::compile::(world); - let result = output.and_then(|bundle| export_bundle(bundle, config)); - Warned { output: result, warnings } - } + OutputFormat::Bundle => typst::compile::(world) + .and_then(|bundle| export_bundle(bundle, config)), } } /// Export to HTML. fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult<()> { - let options = HtmlOptions { pretty: config.pretty }; + let options = html_options(config); let html = typst_html::html(document, &options)?; let result = config.output.write(html.as_bytes()); @@ -365,35 +386,42 @@ fn export_html(document: &HtmlDocument, config: &CompileConfig) -> SourceResult< fn export_paged( document: &PagedDocument, config: &CompileConfig, -) -> SourceResult> { +) -> Warned>> { match config.output_format { OutputFormat::Pdf => { - export_pdf(document, config).map(|()| vec![config.output.clone()]) - } - OutputFormat::Png => { - export_image(document, config, ImageExportFormat::Png).at(Span::detached()) - } - OutputFormat::Svg => { - export_image(document, config, ImageExportFormat::Svg).at(Span::detached()) + export_pdf(document, config).and_then(|()| Ok(vec![config.output.clone()])) } + OutputFormat::Png => export_image(document, config, ImageExportFormat::Png) + .at(Span::detached()) + .into(), + OutputFormat::Svg => export_image(document, config, ImageExportFormat::Svg) + .at(Span::detached()) + .into(), OutputFormat::Html | OutputFormat::Bundle => unreachable!(), } } /// Export to a PDF. -fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult<()> { +fn export_pdf( + document: &PagedDocument, + config: &CompileConfig, +) -> Warned> { let options = pdf_options(config); - let buffer = typst_pdf::pdf(document, &options)?; - config - .output - .write(&buffer) - .map_err(|err| eco_format!("failed to write PDF file ({err})")) - .at(Span::detached())?; - Ok(()) + typst_pdf::pdf(document, &options).and_then(|buffer| { + config + .output + .write(&buffer) + .map_err(|err| eco_format!("failed to write PDF file ({err})")) + .at(Span::detached())?; + Ok(()) + }) } /// Export to a bundle, a collection of files in a directory. -fn export_bundle(bundle: Bundle, config: &CompileConfig) -> SourceResult> { +fn export_bundle( + bundle: Bundle, + config: &CompileConfig, +) -> Warned>> { let options = BundleOptions { html: html_options(config), pdf: pdf_options(config), @@ -401,22 +429,23 @@ fn export_bundle(bundle: Bundle, config: &CompileConfig) -> SourceResult path, - Output::Stdout => { - bail!(Span::detached(), "cannot write bundle to standard output") - } - }; + typst_bundle::export(&bundle, &options).and_then(|fs| { + let root = match &config.output { + Output::Path(path) => path, + Output::Stdout => { + bail!(Span::detached(), "cannot write bundle to standard output") + } + }; - let outputs = write_virtual_fs(root, &fs).at(Span::detached())?; + let outputs = write_virtual_fs(root, &fs).at(Span::detached())?; - #[cfg(feature = "http-server")] - if let Some(server) = &config.server { - server.set_bundle(bundle, fs); - } + #[cfg(feature = "http-server")] + if let Some(server) = &config.server { + server.set_bundle(bundle, fs); + } - Ok(outputs) + Ok(outputs) + }) } /// Writes a bundle's files to disk. @@ -469,6 +498,46 @@ fn export_image( config: &CompileConfig, fmt: ImageExportFormat, ) -> StrResult> { + match fmt { + ImageExportFormat::Png => { + let options = + png_options(config).resolve(document.options().get::()); + export_image_pages(config, document, |page, output| { + let pixmap = typst_render::render(page, &options); + let buf = pixmap + .encode_png() + .map_err(|err| eco_format!("failed to encode PNG file ({err})"))?; + output + .write(&buf) + .map_err(|err| eco_format!("failed to write PNG file ({err})"))?; + Ok(()) + }) + } + ImageExportFormat::Svg => { + let options = + svg_options(config).resolve(document.options().get::()); + export_image_pages(config, document, |page, output| { + let svg = typst_svg::svg(page, &options); + output + .write(svg.as_bytes()) + .map_err(|err| eco_format!("failed to write SVG file ({err})"))?; + Ok(()) + }) + } + } +} + +/// Exports the document's pages as separate images, each exported with a +/// format-specific `export_image_page` function. +fn export_image_pages( + config: &CompileConfig, + document: &PagedDocument, + export_image_page: F, +) -> StrResult> +where + F: Fn(&Page, &Output) -> StrResult<()>, + F: Send + Sync, +{ // Determine whether we have indexable templates in output let can_handle_multiple = match config.output { Output::Stdout => false, @@ -524,7 +593,7 @@ fn export_image( && config.export_cache.is_cached(*i, page) && path.exists() { - return Ok(Output::Path(path.to_path_buf())); + return Ok(Output::Path(path.to_owned())); } Output::Path(path.to_owned()) @@ -532,7 +601,8 @@ fn export_image( Output::Stdout => Output::Stdout, }; - export_image_page(config, page, &output, fmt)?; + export_image_page(page, &output)?; + Ok(output) }) .collect::>>() @@ -567,38 +637,11 @@ mod output_template { } } -/// Export single image. -fn export_image_page( - config: &CompileConfig, - page: &Page, - output: &Output, - fmt: ImageExportFormat, -) -> StrResult<()> { - match fmt { - ImageExportFormat::Png => { - let options = png_options(config); - let pixmap = typst_render::render(page, &options); - let buf = pixmap - .encode_png() - .map_err(|err| eco_format!("failed to encode PNG file ({err})"))?; - output - .write(&buf) - .map_err(|err| eco_format!("failed to write PNG file ({err})"))?; - } - ImageExportFormat::Svg => { - let options = svg_options(config); - let svg = typst_svg::svg(page, &options); - output - .write(svg.as_bytes()) - .map_err(|err| eco_format!("failed to write SVG file ({err})"))?; - } - } - Ok(()) -} - /// Creates options for HTML export. fn html_options(config: &CompileConfig) -> HtmlOptions { - HtmlOptions { pretty: config.pretty } + HtmlOptions { + format: HtmlFormatOptions { pretty: config.pretty }, + } } /// Creates options for PDF export. @@ -622,23 +665,30 @@ fn pdf_options(config: &CompileConfig) -> PdfOptions { ident: Smart::Auto, creator: Smart::Auto, timestamp, - page_ranges: config.pages.clone(), - standards: config.pdf_standards.clone(), - tagged: config.tagged, - pretty: config.pretty, + format: PdfFormatOptions { + pages: config.pages.clone().map(Some), + standard: config.pdf_standards.clone(), + tagged: config.tagged.map(Smart::Custom), + pretty: config.pretty, + }, } } /// Creates options for SVG export. fn svg_options(config: &CompileConfig) -> SvgOptions { - SvgOptions { render_bleed: false, pretty: config.pretty } + SvgOptions { + render_bleed: false, + format: SvgFormatOptions { pretty: config.pretty }, + } } /// Creates options for PNG export. fn png_options(config: &CompileConfig) -> RenderOptions { RenderOptions { - pixel_per_pt: Scalar::new(config.ppi / 72.0), render_bleed: false, + format: PngFormatOptions { + pixel_per_pt: config.ppi.map(|ppi| Scalar::new(ppi / 72.0)), + }, } } @@ -737,26 +787,26 @@ pub fn print_diagnostics( ) } -impl From for typst_pdf::PdfStandard { - fn from(standard: PdfStandard) -> Self { +impl From for typst_pdf::PdfStandard { + fn from(standard: args::PdfStandard) -> Self { match standard { - PdfStandard::V_1_4 => typst_pdf::PdfStandard::V_1_4, - PdfStandard::V_1_5 => typst_pdf::PdfStandard::V_1_5, - PdfStandard::V_1_6 => typst_pdf::PdfStandard::V_1_6, - PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, - PdfStandard::V_2_0 => typst_pdf::PdfStandard::V_2_0, - PdfStandard::A_1b => typst_pdf::PdfStandard::A_1b, - PdfStandard::A_1a => typst_pdf::PdfStandard::A_1a, - PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, - PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u, - PdfStandard::A_2a => typst_pdf::PdfStandard::A_2a, - PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, - PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u, - PdfStandard::A_3a => typst_pdf::PdfStandard::A_3a, - PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, - PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, - PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, - PdfStandard::UA_1 => typst_pdf::PdfStandard::Ua_1, + args::PdfStandard::V_1_4 => typst_pdf::PdfStandard::V_1_4, + args::PdfStandard::V_1_5 => typst_pdf::PdfStandard::V_1_5, + args::PdfStandard::V_1_6 => typst_pdf::PdfStandard::V_1_6, + args::PdfStandard::V_1_7 => typst_pdf::PdfStandard::V_1_7, + args::PdfStandard::V_2_0 => typst_pdf::PdfStandard::V_2_0, + args::PdfStandard::A_1b => typst_pdf::PdfStandard::A_1b, + args::PdfStandard::A_1a => typst_pdf::PdfStandard::A_1a, + args::PdfStandard::A_2b => typst_pdf::PdfStandard::A_2b, + args::PdfStandard::A_2u => typst_pdf::PdfStandard::A_2u, + args::PdfStandard::A_2a => typst_pdf::PdfStandard::A_2a, + args::PdfStandard::A_3b => typst_pdf::PdfStandard::A_3b, + args::PdfStandard::A_3u => typst_pdf::PdfStandard::A_3u, + args::PdfStandard::A_3a => typst_pdf::PdfStandard::A_3a, + args::PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, + args::PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, + args::PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, + args::PdfStandard::UA_1 => typst_pdf::PdfStandard::UA_1, } } } diff --git a/crates/typst-cli/src/eval.rs b/crates/typst-cli/src/eval.rs index a79317a68f31..ca6dcd61ce14 100644 --- a/crates/typst-cli/src/eval.rs +++ b/crates/typst-cli/src/eval.rs @@ -39,11 +39,11 @@ pub fn eval(command: &'static EvalCommand) -> HintedStrResult<()> { // Compile the main file and get the introspector. let Warned { output, mut warnings } = match command.target { Target::Paged => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), Target::Html => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), Target::Bundle => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), }; match output { diff --git a/crates/typst-cli/src/query.rs b/crates/typst-cli/src/query.rs index 9cab90489850..183c3b0c5c4c 100644 --- a/crates/typst-cli/src/query.rs +++ b/crates/typst-cli/src/query.rs @@ -32,11 +32,11 @@ pub fn query(command: &'static QueryCommand) -> HintedStrResult<()> { let Warned { output, mut warnings } = match command.target { Target::Paged => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), Target::Html => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), Target::Bundle => typst::compile::(&world) - .map(|result| result.map(|output| Box::new(output) as Box)), + .map(|output| Box::new(output) as Box), }; // Add deprecation warning. diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index e1a1c429372b..19d795e070e7 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -64,7 +64,16 @@ impl SystemWorld { let features = process_args.features.iter().copied().map(Into::into).collect(); - Library::builder().with_inputs(inputs).with_features(features).build() + Library::builder([ + typst_html::FORMAT, + typst_pdf::FORMAT, + typst_svg::FORMAT, + typst_render::FORMAT, + typst_bundle::FORMAT, + ]) + .with_inputs(inputs) + .with_features(features) + .build() }; let now = match world_args.creation_timestamp { diff --git a/crates/typst-cli/tests/smoke.rs b/crates/typst-cli/tests/smoke.rs index 3ca7144a7609..d6aa2889d7ef 100644 --- a/crates/typst-cli/tests/smoke.rs +++ b/crates/typst-cli/tests/smoke.rs @@ -41,6 +41,54 @@ fn test_compile_pdf_version() { .must_contain(format!("/Creator(Typst {version})").as_bytes()); } +#[test] +fn test_compile_pdf_tagged_and_pages() { + let project = tempfs(); + let hello = project.write("hello.typ", ""); + let output = exec() + .arg("compile") + .arg("--pdf-tagged") + .arg("--pages=1") + .arg(&hello) + .must_fail(); + output + .stderr + .must_contain("cannot enable PDF tags when exporting a page range"); +} + +#[test] +fn test_compile_pages_warning_untagged() { + let project = tempfs(); + let hello = project.write("hello.typ", ""); + let output = exec().arg("compile").arg("--pages=1").arg(&hello).must_succeed(); + output + .stderr + .must_contain("using `--pages` implies `--pdf-tagged=false`"); +} + +#[test] +fn test_compile_pdf_untagged_and_accessible_standard() { + let project = tempfs(); + let hello = project.write("hello.typ", ""); + let output = exec() + .arg("compile") + .arg("--pdf-tagged=false") + .arg("--pdf-standard=a-3a") + .arg(&hello) + .must_fail(); + output + .stderr + .must_contain("cannot disable PDF tags when exporting a PDF/A-3a document"); +} + +#[test] +fn test_compile_no_pdf_tags_deprecated() { + let project = tempfs(); + let hello = project.write("hello.typ", ""); + let output = exec().arg("compile").arg("--no-pdf-tags").arg(&hello).must_succeed(); + output.stderr.must_contain("`--no-pdf-tags` is deprecated"); +} + #[test] fn test_eval() { let output = exec().arg("eval").arg("1+2").must_succeed(); diff --git a/crates/typst-html/src/document.rs b/crates/typst-html/src/document.rs index e55eea7ba76b..7c15c4db18c8 100644 --- a/crates/typst-html/src/document.rs +++ b/crates/typst-html/src/document.rs @@ -2,6 +2,7 @@ use comemo::{Track, Tracked, TrackedMut}; use ecow::{EcoVec, eco_vec}; use typst_library::diag::{SourceResult, bail, error}; use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{Content, NativeElement, StyleChain, Styles}; use typst_library::introspection::{ Introspector, Locator, LocatorLink, QueryIntrospection, @@ -10,7 +11,7 @@ use typst_library::math::EquationElem; use typst_library::model::{DocumentInfo, FootnoteContainer, FootnoteMarker}; use typst_library::routines::{Arenas, RealizationKind}; use typst_library::{Library, World}; -use typst_syntax::Span; +use typst_syntax::{Span, Spanned}; use typst_utils::{LazyHash, Protected}; use crate::convert::{ConversionLevel, Whitespace}; @@ -159,9 +160,11 @@ fn html_document_common( let mut info = DocumentInfo::default(); info.populate(styles); info.populate_locale(styles); + let mut options = DocumentFormatOptions::new(&library.formats); + options.populate(Spanned::detached(styles)); let children = (engine.library.routines.realize)( - RealizationKind::Document { info: &mut info }, + RealizationKind::Document { info: &mut info, options: &mut options }, &mut engine, &mut locator, &arenas, @@ -214,7 +217,7 @@ fn html_document_common( ); } - Ok(HtmlDocument::new(output, info)) + Ok(HtmlDocument::new(output, info, options)) } /// The introspectible output of HTML compilation. diff --git a/crates/typst-html/src/dom.rs b/crates/typst-html/src/dom.rs index a6ed85bd8df1..490b158e7bcd 100644 --- a/crates/typst-html/src/dom.rs +++ b/crates/typst-html/src/dom.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use ecow::{EcoString, EcoVec}; use typst_library::diag::{HintedStrResult, SourceResult, StrResult, bail}; use typst_library::engine::Engine; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{ Content, Dict, Fold, Output, Repr, Str, StyleChain, Target, cast, }; @@ -26,16 +27,26 @@ use crate::{HtmlIntrospector, charsets, css}; pub struct HtmlDocument { output: HtmlOutput, info: DocumentInfo, + options: DocumentFormatOptions, introspector: Arc, } impl HtmlDocument { - /// Creates a new paged document from its parts. + /// Creates a new HTML document from its parts. /// /// Internally builds the introspector. - pub fn new(output: HtmlOutput, info: DocumentInfo) -> Self { + pub fn new( + output: HtmlOutput, + info: DocumentInfo, + options: DocumentFormatOptions, + ) -> Self { let introspector = HtmlIntrospector::new(output.nodes()); - Self { output, info, introspector: Arc::new(introspector) } + Self { + output, + info, + options, + introspector: Arc::new(introspector), + } } /// The document's root HTML element. @@ -76,6 +87,10 @@ impl Document for HtmlDocument { fn info(&self) -> &DocumentInfo { &self.info } + + fn options(&self) -> &DocumentFormatOptions { + &self.options + } } impl Output for HtmlDocument { diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index ee1bcbd5b3b6..6699f188e5d5 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -3,26 +3,35 @@ use std::fmt::Write; use comemo::{Track, Tracked}; use ecow::{EcoString, eco_format}; use typst_library::diag::{At, SourceResult, StrResult, bail}; +use typst_library::format::{Complete, Fields, Partial}; use typst_library::foundations::Repr; -use typst_library::model::LateLinkResolver; +use typst_library::model::{Document, LateLinkResolver}; use typst_syntax::Span; use crate::{ - HtmlDocument, HtmlElement, HtmlFrame, HtmlNode, HtmlTag, attr, charsets, property, - tag, + HtmlDocument, HtmlElement, HtmlFormat, HtmlFormatOptions, HtmlFrame, HtmlNode, + HtmlTag, attr, charsets, property, tag, }; /// Settings for HTML export. #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] -pub struct HtmlOptions { - /// Whether to format the HTML in a human-readable way. - pub pretty: bool, +pub struct HtmlOptions { + /// Format options that override the defaults set by the document. + pub format: HtmlFormatOptions, +} + +impl HtmlOptions { + /// Resolves the final options given those set by the document. + pub fn resolve(&self, doc: &HtmlFormatOptions) -> HtmlOptions { + HtmlOptions { format: self.format.resolve(doc) } + } } /// Encodes an HTML document into a string. pub fn html(document: &HtmlDocument, options: &HtmlOptions) -> SourceResult { + let options = options.resolve(document.options().get::()); let link_resolver = LateLinkResolver::new(None, document.introspector().as_ref()); - let w = Writer::new(link_resolver.track(), options.pretty); + let w = Writer::new(link_resolver.track(), options.format.pretty.v); html_impl(w, document.root()) } @@ -32,10 +41,10 @@ pub fn html(document: &HtmlDocument, options: &HtmlOptions) -> SourceResult, link_resolver: Tracked, ) -> SourceResult { - let w = Writer::new(link_resolver, options.pretty); + let w = Writer::new(link_resolver, options.format.pretty.v); html_impl(w, root) } diff --git a/crates/typst-html/src/format.rs b/crates/typst-html/src/format.rs new file mode 100644 index 000000000000..3f78c9f32b47 --- /dev/null +++ b/crates/typst-html/src/format.rs @@ -0,0 +1,282 @@ +use ecow::EcoString; +use typst_library::Feature; +use typst_library::diag::{SourceResult, bail}; +use typst_library::engine::Engine; +use typst_library::format::{Complete, Fields, Format, FormatElement, Partial, Populate}; +use typst_library::foundations::{Args, Construct, Content, Scope, StyleChain}; +use typst_library::introspection::Location; +use typst_macros::{elem, scope}; +use typst_syntax::Spanned; + +use crate::{HtmlAttr, HtmlAttrs, HtmlTag, css}; + +/// The format element for registering the HTML format. +pub const FORMAT: Format = Format::new::().with_feature(Feature::Html); + +/// Typst's HTML export format (experimental). +/// +/// #info[ +/// Typst's HTML export is currently under active development. The feature is +/// still very incomplete and only available for experimentation behind a +/// feature flag. Do not use this feature for production use cases. In the +/// CLI, you can experiment with HTML export by passing `--features html` or +/// setting the `TYPST_FEATURES` environment variable to `html`. In the web +/// app, you can likewise enable it as an experimental feature. Visit the +/// #link("https://github.com/typst/typst/issues/5512")[tracking issue] to +/// follow progress on HTML export and learn more about planned features. +/// ] +/// +/// HTML files describe a document structurally. The aim of Typst's HTML export +/// is to capture the structure of an input document and produce semantically +/// rich HTML that retains this structure. The resulting HTML should be +/// accessible, human-readable, and editable by hand and downstream tools. +/// +/// PDF, PNG, and SVG export, in contrast, all produce _visual_ representations +/// of a fully-laid out document. This divergence in the formats' intents means +/// that Typst cannot simply produce perfect HTML for your existing Typst +/// documents. It cannot always know what the best semantic HTML representation +/// of your content is. +/// +/// Instead, it gives _you_ full control: You can check the current export +/// format through the @target function and when it is set to HTML, generate +/// @html.elem[raw HTML elements]. The primary intended use of these elements is +/// in templates and show rules. This way, the document's contents can be fully +/// agnostic to the export target and content can be shared between PDF and HTML +/// export. +/// +/// In the `html` export format, Typst will output a single HTML file. You can +/// also use Typst to create a website comprising multiple HTML documents and +/// additional assets. Typst calls such a collection a _bundle_ and supports +/// this use case with the dedicated @format.bundle[`bundle` target]. Refer to +/// its documentation to learn more. Note that both in the `html` and the +/// `bundle` target, Typst will currently always emit a standalone HTML file. +/// Support for emitting fragments that can be integrated into other HTML +/// documents is planned for the future. +/// +/// Typst currently does not output CSS style sheets, instead focussing on +/// emitting semantic markup. You can of course write your own CSS styles and +/// still benefit from sharing your _content_ between PDF and HTML. For the +/// future, we plan to give you the option of automatically emitting CSS, taking +/// more of your existing set rules into account. +/// +/// = Exporting as HTML +/// == Command Line +/// Pass `--format html` to the `compile` or `watch` subcommand or provide an +/// output file name that ends with `.html`. Note that you must also pass +/// `--features html` or set `TYPST_FEATURES=html` to enable this experimental +/// export target. +/// +/// When using `typst watch`, Typst will launch a live-reloading HTTP server. +/// You can configure it as follows: +/// +/// - Pass `--port` to change the port. (Defaults to the first free port in the +/// range 3000-3005.) +/// - Pass `--no-reload` to disable injection of a live reload script. (The HTML +/// that is written to disk isn't affected either way.) +/// - Pass `--no-serve` to disable the server altogether. +/// +/// == Web App +/// HTML export can be enabled as an experimental feature in the web app. +/// #insertion("html-web-app") +/// +/// = HTML-specific functionality +/// Typst exposes HTML-specific functionality in the global `html` element. See +/// below for the definitions and options it contains. +/// +/// #category-settings(groups: ( +/// ( +/// name: "typed", +/// title: "Typed HTML", +/// items: dictionary(stdx.describe(html).scope) +/// .filter(val => "typed-html" in stdx.describe(val).keywords), +/// description: "Documentation for the typed HTML API.", +/// docs: [ +/// A typed layer over raw HTML elements. +/// +/// The `html` module provides a typed layer over the raw @html.elem +/// function that allows you to conveniently create HTML elements. HTML +/// attributes are exposed as function parameters that accept Typst +/// types and automatically take care of converting those into the +/// appropriate HTML. +/// +/// Some parameters are common to all typed HTML functions. These are +/// listed at the bottom in the +/// @html:typed:global-attributes[Global Attributes] section instead of +/// explicitly on each element for readability. +/// +/// = Example +/// ```typ +/// #html.video( +/// controls: true, +/// width: 1280, +/// height: 720, +/// src: "sunrise.mp4", +/// )[ +/// Your browser does not support the video tag. +/// ] +/// ``` +/// ], +/// epilogue: [ +/// = Global Attributes +/// These parameters are common to all typed HTML functions. They are +/// listed here once instead of explicitly on each element for +/// readability. +/// ], +/// ), +/// )) +#[elem(scope, name = "html", title = "HTML", since = "0.13.0", Construct)] +pub struct HtmlFormat { + /// Whether to pretty-print the produced HTML document. + /// + /// This formats the output in a more human-readable, but less + /// space-efficient way. + #[default(false)] + pub pretty: bool, +} + +impl Construct for HtmlFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +impl FormatElement for HtmlFormat { + type Options = HtmlFormatOptions; +} + +#[scope(category = Html)] +impl HtmlFormat { + #[elem] + type HtmlElem; + + #[elem] + type FrameElem; + + #[defs] + fn definitions(scope: &mut Scope) { + crate::typed::define(scope); + } +} + +/// Document settings for HTML export. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct HtmlFormatOptions { + pub pretty: F::Value, +} + +impl Populate for HtmlFormatOptions { + fn populate(&mut self, styles: Spanned) { + // VOLATILE: This must be updated when adding more fields. + self.pretty.populate(styles); + } +} + +impl HtmlFormatOptions { + /// Resolves the [`Partial`] options to [`Complete`] ones, given defaults. + pub fn resolve(&self, default: &HtmlFormatOptions) -> HtmlFormatOptions { + HtmlFormatOptions { + pretty: Partial::resolve(self.pretty, default.pretty), + } + } +} + +/// An HTML element that can contain Typst content. +/// +/// Typst's HTML export automatically generates the appropriate tags for most +/// elements. However, sometimes, it is desirable to retain more control. For +/// example, when using Typst to generate your blog, you could use this function +/// to wrap each article in an `

` tag. +/// +/// Typst is aware of what is valid HTML. A tag and its attributes must form +/// syntactically valid HTML. Some tags, like `meta` do not accept content. +/// Hence, you must not provide a body for them. We may add more checks in the +/// future, so be sure that you are generating valid HTML when using this +/// function. +/// +/// Normally, Typst will generate `html`, `head`, and `body` tags for you. If +/// you instead create them with this function, Typst will omit its own tags. +/// +/// ```typ +/// #html.elem("div", attrs: (style: "background: aqua"))[ +/// A div with _Typst content_ inside! +/// ] +/// ``` +#[elem(name = "elem", since = "0.13.0")] +pub struct HtmlElem { + /// The element's tag. + #[required] + pub tag: HtmlTag, + + /// The element's HTML attributes. + #[fold] + pub attrs: HtmlAttrs, + + /// The element's CSS properties. Currently only used for generated styles. + #[internal] + #[parse(Some(css::Properties::default()))] + pub css: css::Properties, + + /// The contents of the HTML element. + /// + /// The body can be arbitrary Typst content. + #[positional] + pub body: Option, + + /// The element's logical parent, if any. + #[internal] + #[synthesized] + pub parent: Location, + + /// A role that should be applied to the top-level styled HTML element, but + /// not its descendants. If we ever get set rules that apply to a specific + /// element instead of a subtree, they could supplant this. If we need the + /// same mechanism for things like `class`, this could potentially also be + /// extended to arbitrary attributes. It's minimal for now. + /// + /// This is ignored for `

` elements as it otherwise tends to + /// unintentionally attach to paragraphs resulting from grouping of a single + /// element instead of attaching to that element. This is a bit of a hack, + /// but good enough for now as the `role` property is purely internal and we + /// control what it is used for. + #[internal] + #[ghost] + pub role: Option, +} + +impl HtmlElem { + /// Add an attribute to the element. + pub fn with_attr(mut self, attr: HtmlAttr, value: impl Into) -> Self { + self.attrs + .as_option_mut() + .get_or_insert_with(Default::default) + .push(attr, value); + self + } + + /// Adds the attribute to the element if value is not `None`. + pub fn with_optional_attr( + self, + attr: HtmlAttr, + value: Option>, + ) -> Self { + if let Some(value) = value { self.with_attr(attr, value) } else { self } + } +} + +/// An element that lays out its content as an inline SVG. +/// +/// Sometimes, converting Typst content to HTML is not desirable. This can be +/// the case for plots and other content that relies on positioning and styling +/// to convey its message. +/// +/// This function allows you to use the Typst layout engine that would also be +/// used for PDF, SVG, and PNG export to render a part of your document exactly +/// how it would appear when exported in one of these formats. It embeds the +/// content as an inline SVG. +#[elem(since = "0.13.0")] +pub struct FrameElem { + /// The content that shall be laid out. + #[positional] + #[required] + pub body: Content, +} diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index e9acf4a0b7eb..4ff6b2c6b5a6 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -10,6 +10,7 @@ mod css; mod document; mod dom; mod encode; +mod format; mod fragment; mod introspect; mod link; @@ -20,123 +21,7 @@ mod typed; pub use self::document::{html_document, html_document_for_bundle}; pub use self::dom::*; pub use self::encode::{HtmlOptions, html, html_in_bundle}; +pub use self::format::{FORMAT, FrameElem, HtmlElem, HtmlFormat, HtmlFormatOptions}; pub use self::introspect::HtmlIntrospector; pub use self::link::create_link_anchors; pub use self::rules::{html_mathml_body, html_span_filled, register}; - -use ecow::EcoString; -use typst_library::Category; -use typst_library::foundations::{Content, Module, Scope}; -use typst_library::introspection::Location; -use typst_macros::elem; - -/// Creates the module with all HTML definitions. -pub fn module() -> Module { - let mut html = Scope::deduplicating(); - html.start_category(Category::Html); - html.define_elem::(); - html.define_elem::(); - crate::typed::define(&mut html); - Module::new("html", html) -} - -/// An HTML element that can contain Typst content. -/// -/// Typst's HTML export automatically generates the appropriate tags for most -/// elements. However, sometimes, it is desirable to retain more control. For -/// example, when using Typst to generate your blog, you could use this function -/// to wrap each article in an `

` tag. -/// -/// Typst is aware of what is valid HTML. A tag and its attributes must form -/// syntactically valid HTML. Some tags, like `meta` do not accept content. -/// Hence, you must not provide a body for them. We may add more checks in the -/// future, so be sure that you are generating valid HTML when using this -/// function. -/// -/// Normally, Typst will generate `html`, `head`, and `body` tags for you. If -/// you instead create them with this function, Typst will omit its own tags. -/// -/// ```typ -/// #html.elem("div", attrs: (style: "background: aqua"))[ -/// A div with _Typst content_ inside! -/// ] -/// ``` -#[elem(name = "elem", since = "0.13.0")] -pub struct HtmlElem { - /// The element's tag. - #[required] - pub tag: HtmlTag, - - /// The element's HTML attributes. - #[fold] - pub attrs: HtmlAttrs, - - /// The element's CSS properties. Currently only used for generated styles. - #[internal] - #[parse(Some(css::Properties::default()))] - pub css: css::Properties, - - /// The contents of the HTML element. - /// - /// The body can be arbitrary Typst content. - #[positional] - pub body: Option, - - /// The element's logical parent, if any. - #[internal] - #[synthesized] - pub parent: Location, - - /// A role that should be applied to the top-level styled HTML element, but - /// not its descendants. If we ever get set rules that apply to a specific - /// element instead of a subtree, they could supplant this. If we need the - /// same mechanism for things like `class`, this could potentially also be - /// extended to arbitrary attributes. It's minimal for now. - /// - /// This is ignored for `

` elements as it otherwise tends to - /// unintentionally attach to paragraphs resulting from grouping of a single - /// element instead of attaching to that element. This is a bit of a hack, - /// but good enough for now as the `role` property is purely internal and we - /// control what it is used for. - #[internal] - #[ghost] - pub role: Option, -} - -impl HtmlElem { - /// Add an attribute to the element. - pub fn with_attr(mut self, attr: HtmlAttr, value: impl Into) -> Self { - self.attrs - .as_option_mut() - .get_or_insert_with(Default::default) - .push(attr, value); - self - } - - /// Adds the attribute to the element if value is not `None`. - pub fn with_optional_attr( - self, - attr: HtmlAttr, - value: Option>, - ) -> Self { - if let Some(value) = value { self.with_attr(attr, value) } else { self } - } -} - -/// An element that lays out its content as an inline SVG. -/// -/// Sometimes, converting Typst content to HTML is not desirable. This can be -/// the case for plots and other content that relies on positioning and styling -/// to convey its message. -/// -/// This function allows you to use the Typst layout engine that would also be -/// used for PDF, SVG, and PNG export to render a part of your document exactly -/// how it would appear when exported in one of these formats. It embeds the -/// content as an inline SVG. -#[elem(since = "0.13.0")] -pub struct FrameElem { - /// The content that shall be laid out. - #[positional] - #[required] - pub body: Content, -} diff --git a/crates/typst-ide/Cargo.toml b/crates/typst-ide/Cargo.toml index 60efc8c30ab4..e9631d03ce64 100644 --- a/crates/typst-ide/Cargo.toml +++ b/crates/typst-ide/Cargo.toml @@ -27,7 +27,11 @@ unscanny = { workspace = true } [dev-dependencies] typst-assets = { workspace = true, features = ["fonts"] } +typst-bundle = { workspace = true } typst-dev-assets = { workspace = true } +typst-pdf = { workspace = true } +typst-render = { workspace = true } +typst-svg = { workspace = true } once_cell = { workspace = true } [lints] diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index bac78762ecb9..a2fe48861b7d 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -175,7 +175,15 @@ fn library() -> Library { // Set page width to 120pt with 10pt margins, so that the inner page is // exactly 100pt wide. Page height is unbounded and font size is 10pt so // that it multiplies to nice round numbers. - let mut lib = typst::Library::builder().with_features(Features::all()).build(); + let mut lib = typst::Library::builder([ + typst_html::FORMAT, + typst_pdf::FORMAT, + typst_svg::FORMAT, + typst_render::FORMAT, + typst_bundle::FORMAT, + ]) + .with_features(Features::all()) + .build(); lib.styles.set(PageElem::width, Smart::Custom(Abs::pt(120.0).into())); lib.styles.set(PageElem::height, Smart::Auto); lib.styles.set( diff --git a/crates/typst-layout/src/document.rs b/crates/typst-layout/src/document.rs index b075c20feafe..a615d1c5c89c 100644 --- a/crates/typst-layout/src/document.rs +++ b/crates/typst-layout/src/document.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use ecow::EcoVec; use typst_library::diag::SourceResult; use typst_library::engine::Engine; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{Content, Output, Smart, StyleChain, Target}; use typst_library::introspection::Introspector; use typst_library::layout::{Abs, Frame, Sides}; @@ -17,6 +18,7 @@ use crate::PagedIntrospector; pub struct PagedDocument { pages: EcoVec, info: DocumentInfo, + options: DocumentFormatOptions, introspector: Arc, } @@ -24,9 +26,18 @@ impl PagedDocument { /// Creates a new paged document from its parts. /// /// Internally builds the introspector. - pub fn new(pages: EcoVec, info: DocumentInfo) -> Self { + pub fn new( + pages: EcoVec, + info: DocumentInfo, + options: DocumentFormatOptions, + ) -> Self { let introspector = PagedIntrospector::new(&pages); - Self { pages, info, introspector: Arc::new(introspector) } + Self { + pages, + info, + options, + introspector: Arc::new(introspector), + } } /// The document's finished pages. @@ -58,6 +69,10 @@ impl Document for PagedDocument { fn info(&self) -> &DocumentInfo { &self.info } + + fn options(&self) -> &DocumentFormatOptions { + &self.options + } } impl Output for PagedDocument { diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs index f590d39e1608..c912879032a3 100644 --- a/crates/typst-layout/src/flow/compose.rs +++ b/crates/typst-layout/src/flow/compose.rs @@ -11,10 +11,10 @@ use typst_library::layout::{ Abs, Axes, Dir, FixedAlignment, Fragment, Frame, FrameItem, FrameParent, Inherit, OuterHAlignment, PlacementScope, Point, Region, Regions, Rel, Size, }; +use typst_library::model::ArtifactKind; use typst_library::model::{ FootnoteElem, FootnoteEntry, LineNumberingScope, Numbering, ParLineMarker, }; -use typst_library::pdf::ArtifactKind; use typst_syntax::Span; use typst_utils::{NonZeroExt, Numeric}; diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs index b5c4b571752d..611b6c99156a 100644 --- a/crates/typst-layout/src/flow/mod.rs +++ b/crates/typst-layout/src/flow/mod.rs @@ -24,8 +24,9 @@ use typst_library::layout::{ Abs, Angle, ColumnsElem, Dir, Em, Fragment, Frame, HAlignment, PageElem, PlacementScope, Region, Regions, Rel, Size, VAlignment, }; -use typst_library::model::{FootnoteElem, FootnoteEntry, LineNumberingScope, ParLine}; -use typst_library::pdf::ArtifactKind; +use typst_library::model::{ + ArtifactKind, FootnoteElem, FootnoteEntry, LineNumberingScope, ParLine, +}; use typst_library::routines::{Arenas, FragmentKind, Pair, RealizationKind}; use typst_library::text::TextElem; use typst_library::visualize::LineElem; diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs index e36da1d0ff09..f950d0d02ba3 100644 --- a/crates/typst-layout/src/lists.rs +++ b/crates/typst-layout/src/lists.rs @@ -7,8 +7,9 @@ use typst_library::introspection::Locator; use typst_library::layout::{ Abs, Axes, Dir, Fragment, Frame, FrameItem, Length, Point, Region, Regions, Size, }; -use typst_library::model::{EnumElem, ListElem, Numbering, ParElem, ParbreakElem}; -use typst_library::pdf::PdfMarkerTag; +use typst_library::model::{ + EnumElem, ListElem, Numbering, ParElem, ParbreakElem, PdfMarkerTag, +}; use typst_library::text::TextElem; use typst_syntax::Span; diff --git a/crates/typst-layout/src/pages/mod.rs b/crates/typst-layout/src/pages/mod.rs index e67ce569f7ea..9bd2ecba453a 100644 --- a/crates/typst-layout/src/pages/mod.rs +++ b/crates/typst-layout/src/pages/mod.rs @@ -8,6 +8,7 @@ use comemo::{Track, Tracked, TrackedMut}; use ecow::EcoVec; use typst_library::diag::SourceResult; use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::format::DocumentFormatOptions; use typst_library::foundations::{Content, StyleChain}; use typst_library::introspection::{ Introspector, Locator, LocatorLink, ManualPageCounter, SplitLocator, TagElem, @@ -16,6 +17,7 @@ use typst_library::layout::{FrameItem, Point}; use typst_library::model::DocumentInfo; use typst_library::routines::{Arenas, Pair, RealizationKind}; use typst_library::{Library, World}; +use typst_syntax::Spanned; use typst_utils::{LazyHash, Protected}; use self::collect::{Item, collect}; @@ -156,9 +158,11 @@ fn layout_document_common( let mut info = DocumentInfo::default(); info.populate(styles); info.populate_locale(styles); + let mut options = DocumentFormatOptions::new(&library.formats); + options.populate(Spanned::detached(styles)); let mut children = (engine.library.routines.realize)( - RealizationKind::Document { info: &mut info }, + RealizationKind::Document { info: &mut info, options: &mut options }, &mut engine, &mut locator, &arenas, @@ -168,7 +172,7 @@ fn layout_document_common( let pages = layout_pages(&mut engine, &mut children, &mut locator, styles)?; - Ok(PagedDocument::new(pages, info)) + Ok(PagedDocument::new(pages, info, options)) } /// Layouts the document's pages. diff --git a/crates/typst-layout/src/pages/run.rs b/crates/typst-layout/src/pages/run.rs index 3e95090a133e..e8077d1c2871 100644 --- a/crates/typst-layout/src/pages/run.rs +++ b/crates/typst-layout/src/pages/run.rs @@ -12,8 +12,7 @@ use typst_library::layout::{ Length, OuterVAlignment, PageElem, Paper, Region, Regions, Rel, Sides, Size, VAlignment, }; -use typst_library::model::Numbering; -use typst_library::pdf::ArtifactKind; +use typst_library::model::{ArtifactKind, Numbering}; use typst_library::routines::Pair; use typst_library::text::{LocalName, TextElem}; use typst_library::visualize::Paint; diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index 667fd5081bfe..0b2907ef1b88 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -15,6 +15,7 @@ use typst_library::layout::{ Size, Sizing, SkewElem, Spacing, StackChild, StackElem, TrackSizings, VElem, }; use typst_library::math::EquationElem; +use typst_library::model::{ArtifactElem, ArtifactKind, PdfMarkerTag}; use typst_library::model::{ Attribution, BibliographyElem, CiteElem, CiteGroup, CslIndentElem, CslLightElem, Destination, DirectLinkElem, DividerElem, EmphElem, EnumElem, FigureCaption, @@ -22,7 +23,6 @@ use typst_library::model::{ OutlineElem, OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, StrongElem, TableCell, TableElem, TermsElem, TitleElem, Works, }; -use typst_library::pdf::{ArtifactElem, ArtifactKind, AttachElem, PdfMarkerTag}; use typst_library::text::{ DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, LocalName, OverlineElem, RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem, @@ -106,7 +106,6 @@ pub fn register(rules: &mut NativeRuleMap) { rules.register(Paged, EQUATION_RULE); // PDF. - rules.register(Paged, ATTACH_RULE); rules.register(Paged, ARTIFACT_RULE); rules.register(Paged, PDF_MARKER_TAG_RULE); } @@ -821,8 +820,6 @@ const EQUATION_RULE: ShowFn = |elem, _, styles| { } }; -const ATTACH_RULE: ShowFn = |_, _, _| Ok(Content::empty()); - const ARTIFACT_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); const PDF_MARKER_TAG_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); diff --git a/crates/typst-library/src/diag.rs b/crates/typst-library/src/diag.rs index b8fe49685c0d..3e035bee240b 100644 --- a/crates/typst-library/src/diag.rs +++ b/crates/typst-library/src/diag.rs @@ -243,10 +243,7 @@ where } }) .collect(); - if !errors.is_empty() { - return Err(errors); - } - Ok(collected) + if errors.is_empty() { Ok(collected) } else { Err(errors) } } } @@ -279,6 +276,77 @@ where } } +/// Collects an iterator of [`Warned>`]s into a result containg +/// a collection or the accumulated warnings and errors. +/// +/// Unlike normal `FromIterator` for `Result`, this will combine all the errors. +/// This is possible because a [`SourceResult`] can hold multiple errors. +pub trait CollectCombinedWarnedResult { + type Item; + + fn collect_combined_warned_result(self) -> Warned> + where + B: FromIterator; +} + +impl CollectCombinedWarnedResult for I +where + I: Iterator>>, +{ + type Item = T; + + fn collect_combined_warned_result(self) -> Warned> + where + B: FromIterator, + { + let mut warnings = EcoVec::new(); + let mut errors = EcoVec::new(); + let collected = self + .filter_map(|result| { + warnings.extend(result.warnings); + match result.output { + Ok(item) => Some(item), + Err(errs) => { + errors.extend(errs); + None + } + } + }) + .collect(); + let output = if errors.is_empty() { Ok(collected) } else { Err(errors) }; + Warned { output, warnings } + } +} + +/// A variation of [`CollectCombinedWarnedResult`] for parallel rayon iterators. +/// +/// Needs to be a separate trait because we can't have two blanket impls. +pub trait ParallelCollectCombinedWarnedResult { + type Item; + + fn collect_combined_warned_result(self) -> Warned> + where + B: FromIterator; +} + +impl ParallelCollectCombinedWarnedResult for I +where + I: rayon::iter::ParallelIterator>>, + T: Send, +{ + type Item = T; + + fn collect_combined_warned_result(self) -> Warned> + where + B: FromIterator, + { + // A more efficient approach might be possible, but this is simpler and + // pragmatic. The point of this trait is primarily to make the call-site + // convenient, not to maximize efficiency. + self.collect::>().into_iter().collect_combined_warned_result() + } +} + /// An output alongside warnings generated while producing it. #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct Warned { @@ -288,10 +356,50 @@ pub struct Warned { pub warnings: EcoVec, } -impl Warned { +impl Warned> { /// Maps the output, keeping the same warnings. - pub fn map R>(self, f: F) -> Warned { - Warned { output: f(self.output), warnings: self.warnings } + pub fn map R>(self, f: F) -> Warned> { + Warned { + output: self.output.map(f), + warnings: self.warnings, + } + } + + /// Maps the error, if the inner `Result` is `Err`, keeping the same + /// warnings. + pub fn map_err(self, f: F) -> Warned> + where + F: FnOnce(E) -> R, + { + Warned { + output: self.output.map_err(f), + warnings: self.warnings, + } + } + + /// Maps the output, if the inner `Result` is `Ok`. + /// + /// If `f` returns a `Warned`, the additional warnings are added. If it just + /// returns a bare result, the same warnings as before are kept. + pub fn and_then(mut self, f: F) -> Warned> + where + F: FnOnce(T) -> W, + W: Into>>, + { + Warned { + output: self.output.and_then(|output| { + let Warned { output, warnings } = f(output).into(); + self.warnings.extend(warnings); + output + }), + warnings: self.warnings, + } + } +} + +impl From for Warned { + fn from(output: T) -> Self { + Warned { output, warnings: EcoVec::new() } } } diff --git a/crates/typst-library/src/format.rs b/crates/typst-library/src/format.rs new file mode 100644 index 000000000000..50bbee98cd62 --- /dev/null +++ b/crates/typst-library/src/format.rs @@ -0,0 +1,429 @@ +//! Specify configurable export formats. +//! +//! All export formats have to be registered in [`Library::formats`], by passing +//! them to the `typst` crate's +//! `LibraryExt::new()` or `LibraryExt::builder()` methods. +//! +//! Each [`Format`] has an associated [`FormatElement`] with a [`FormatOptions`] +//! type. The format element will be defined on the `std.format` binding, making +//! it available to set rules. +//! +//! The options are stored in the compiled document's [`DocumentFormatOptions`] +//! and can be accessed using the [`Document::options()`] trait method. The +//! concrete type can be retrieved using [`DocumentFormatOptions::get()`] +//! providing the format element as a generic parameter. +//! +//! # Example +//! ``` +//! use typst_library::Library; +//! use typst_library::diag::{SourceResult, bail}; +//! use typst_library::engine::Engine; +//! use typst_library::format::{Complete, Fields, Format, FormatElement, Populate}; +//! use typst_library::foundations::{Args, Construct, Content, StyleChain, elem}; +//! use typst_library::model::Document; +//! use typst_syntax::Spanned; +//! +//! // This can be passed to `Library::new` as `Format::new::()` to +//! // register the format. +//! #[elem(Construct)] +//! pub struct Epub { +//! #[default] +//! pub pretty: bool +//! } +//! +//! impl FormatElement for Epub { +//! type Options = EpubFormatOptions; +//! } +//! +//! impl Construct for Epub { +//! fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { +//! bail!(args.span, "cannot be constructed manually") +//! } +//! } +//! +//! #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +//! pub struct EpubFormatOptions { +//! pub pretty: T::Value, +//! } +//! +//! impl Populate for EpubFormatOptions { +//! fn populate(&mut self, styles: Spanned) { +//! // The `SpannedValue::populate` call automatically stores the span +//! // of the specific set rule. +//! self.pretty.populate(styles); +//! } +//! } +//! +//! +//! // In the export crate. +//! fn export_epub(doc: impl Document) { +//! let options = doc.options().get::(); +//! // Export an EPUB here... +//! } +//! ``` +//! +//! # Internals +//! The [`FormatOptions`] as top-level set-rules have to be special-cased and +//! are handled in a similar way as the [`DocumentInfo`], through the +//! [`RealizationKind::Document`]. +//! +//! [`Library::formats`]: crate::Library::formats +//! [`Document::options()`]: crate::model::Document::options() +//! [`DocumentInfo`]: crate::model::DocumentInfo +//! [`RealizationKind::Document`]: crate::routines::RealizationKind::Document + +use std::any::{Any, TypeId}; +use std::fmt::Debug; +use std::hash::Hash; +use std::marker::PhantomData; + +use ecow::EcoVec; +use typst_syntax::{Span, Spanned}; + +use crate::Feature; +use crate::foundations::{ + Element, Field, Module, NativeElement, NativeRuleMap, Scope, SettableProperty, + StyleChain, +}; + +/// The global `format` module on which all registered formats will be defined. +pub fn module(formats: &[Format]) -> Module { + let mut format = Scope::deduplicating(); + format.start_category(crate::Category::Format); + + for f in formats { + let binding = format.define(f.elem.name(), f.elem); + if let Some(feature) = f.feature { + binding.with_feature(feature); + } + } + + Module::new("format", format) +} + +/// An export format with an associated [`FormatElement`]. +/// +/// See the [module level](self) docs for more information. +#[derive(Debug, Clone, Hash)] +pub struct Format { + pub elem: Element, + options: fn() -> FormatOptions, + feature: Option, + rules: Option, +} + +impl Format { + /// Create a new format with an associated [`FormatElement`]. + pub const fn new() -> Self { + Self { + elem: E::ELEM, + options: || E::Options::default().into(), + feature: None, + rules: None, + } + } + + /// Gate the format behind a feature flag. + pub const fn with_feature(mut self, feature: Feature) -> Self { + self.feature = Some(feature); + self + } + + /// Add format specific rules that will be registered. + pub const fn with_rules(mut self, register: fn(&mut NativeRuleMap)) -> Self { + self.rules = Some(register); + self + } + + /// Get the default format options of this format. + pub fn default_options(&self) -> FormatOptions { + (self.options)() + } + + /// Register the rules specific to this format. + pub fn register_rules(&self, rules: &mut NativeRuleMap) { + if let Some(register) = self.rules { + register(rules); + } + } +} + +/// An export format element with associated options that can be configured +/// using set rules. +/// +/// See the [module level](self) docs for more information. +pub trait FormatElement: NativeElement { + type Options: Populate + Default + Clone + Hash; +} + +/// A type that can be populated from a [`StyleChain`]. +/// +/// This is used inside [`FormatOptions`]. +pub trait Populate: Debug + Any + Send + Sync + 'static { + /// Populate this type with details from the given local styles. + fn populate(&mut self, styles: Spanned); +} + +impl Populate for () { + fn populate(&mut self, _: Spanned) {} +} + +trait Bounds: Populate { + fn dyn_clone(&self) -> Box; + fn dyn_hash(&self, state: &mut dyn std::hash::Hasher); +} + +impl Bounds for T +where + T: Populate + Clone + Hash, +{ + fn dyn_clone(&self) -> Box { + Box::new(self.clone()) + } + + fn dyn_hash(&self, mut state: &mut dyn std::hash::Hasher) { + // Also hash the TypeId since values with different types but + // equal data should be different. + TypeId::of::().hash(&mut state); + self.hash(&mut state); + } +} + +/// A list of format options that have been configured by the document. +/// +/// See the [module level](self) docs for more information. +#[derive(Debug, Clone, Hash)] +pub struct DocumentFormatOptions(EcoVec); + +impl DocumentFormatOptions { + /// Initialize default format options from a list of formats. + pub fn new(formats: &[Format]) -> Self { + Self(formats.iter().map(Format::default_options).collect()) + } + + /// Get a concrete format option type. + /// + /// For example with `typst_pdf`, the `PdfFormatOptions` can be retrieved + /// like this on a compiled document. + /// ```rust,ignore + /// let pdf_options = document.options().get::(); + /// ``` + pub fn get(&self) -> &T::Options { + self.0 + .iter() + .find_map(FormatOptions::downcast::) + .unwrap_or_else(|| { + let format = std::any::type_name::(); + let options = std::any::type_name::(); + panic!( + "format `{format}` with options `{options}` not found\n\ + hint: if you're a developer, you need to register `Library::formats`" + ); + }) + } + + /// Populate the format options with details from the given styles. + pub fn populate(&mut self, styles: Spanned) { + for o in self.0.make_mut() { + o.populate(styles); + } + } +} + +/// Format options that have been configured by the document. +/// +/// See the [module level](self) docs for more information. +pub struct FormatOptions(Box); + +impl FormatOptions { + /// Populate these options from the spanned styles. + pub fn populate(&mut self, styles: Spanned) { + self.0.populate(styles); + } + + /// Attempt to downcast this option to a concrete format option type. + pub fn downcast(&self) -> Option<&T::Options> { + let inner: &dyn Bounds = &*self.0; + (inner as &dyn Any).downcast_ref() + } +} + +impl From for FormatOptions +where + T: Populate + Clone + Hash + Debug + Send + Sync + 'static, +{ + fn from(value: T) -> Self { + Self(Box::new(value)) + } +} + +impl Clone for FormatOptions { + fn clone(&self) -> Self { + Self(self.0.dyn_clone()) + } +} + +impl Hash for FormatOptions { + fn hash(&self, state: &mut H) { + self.0.dyn_hash(state); + } +} + +impl Debug for FormatOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +/// A trait that allows specifying [`Complete`] and [`Partial`] types. +/// +/// Types can take a generic marker and wrap their fields in the +/// [`Fields::Value`] type, which will either wrap them in an `Option` for the +/// [`Partial`] tag or required for the [`Complete`] tag. +/// +/// See the [module level](self) docs for more information. +pub trait Fields: Default { + type Value: Debug + Default + Clone + Eq + Hash + where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash; +} + +/// Marker for types with fully resolved fields. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Complete; + +impl Fields for Complete { + type Value + = SpannedValue + where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash; +} + +/// A [`Spanned`] value that is associtated with a specific [`Field`] of an +/// element. This allows reusing the [`Default`] value specified on the element, +/// by reading from an empty [`StyleChain`]. +#[derive(Debug, Clone, Hash)] +pub struct SpannedValue +where + E: SettableProperty, +{ + /// The format element's field this value should be read from. + field: PhantomData>, + pub v: E::Type, + pub span: Span, +} + +impl SpannedValue +where + E: SettableProperty, +{ + /// Create a new spanned value. + pub fn new(v: E::Type, span: Span) -> Self { + Self { span, v, field: PhantomData } + } + + /// Create a new spanned value with a detached span. + pub fn detached(v: E::Type) -> Self { + Self::new(v, Span::detached()) + } + + /// Populate this value from the given local styles. + /// If the field has been set, this will also update the span. + pub fn populate(&mut self, styles: Spanned) { + if styles.v.has(Field::::new()) { + *self = + SpannedValue::new(styles.v.get_cloned(Field::::new()), styles.span); + } + } +} + +impl Default for SpannedValue +where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash, +{ + fn default() -> Self { + Self::new( + StyleChain::default().get_cloned(Field::::new()), + Span::detached(), + ) + } +} + +impl Copy for SpannedValue +where + E: SettableProperty, + E::Type: Copy, +{ +} + +impl Eq for SpannedValue +where + E: SettableProperty, + E::Type: Eq, +{ +} + +impl PartialEq for SpannedValue +where + E: SettableProperty, + E::Type: PartialEq, +{ + fn eq(&self, other: &Self) -> bool { + self.v == other.v && self.span == other.span + } +} + +impl From> for SpannedValue +where + E: SettableProperty, +{ + fn from(value: Spanned) -> Self { + Self::new(value.v, value.span) + } +} + +/// Marker for types with optional/partial fields. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Partial; + +impl Fields for Partial { + type Value + = Option + where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash; +} + +impl Partial { + /// If present, returns the partial value with a detached span, otherwise + /// the default value is returned. + pub fn resolve( + partial: ::Value, + default: ::Value, + ) -> ::Value + where + E: SettableProperty, + E::Type: Debug + Copy + Clone + Eq + Hash, + { + partial.map(SpannedValue::detached).unwrap_or(default) + } + + /// If present, returns the partial value with a detached span, otherwise + /// the default value is returned. + pub fn resolve_cloned( + partial: &::Value, + default: &::Value, + ) -> ::Value + where + E: SettableProperty, + E::Type: Debug + Clone + Eq + Hash, + { + partial + .clone() + .map(SpannedValue::detached) + .unwrap_or_else(|| default.clone()) + } +} diff --git a/crates/typst-library/src/foundations/content/mod.rs b/crates/typst-library/src/foundations/content/mod.rs index 3e98d3c98081..d1537191b746 100644 --- a/crates/typst-library/src/foundations/content/mod.rs +++ b/crates/typst-library/src/foundations/content/mod.rs @@ -31,8 +31,9 @@ use crate::foundations::{ }; use crate::introspection::Location; use crate::layout::{AlignElem, Alignment, Axes, Length, MoveElem, PadElem, Rel, Sides}; -use crate::model::{Destination, EmphElem, LinkElem, LinkMarker, StrongElem}; -use crate::pdf::{ArtifactElem, ArtifactKind}; +use crate::model::{ + ArtifactElem, ArtifactKind, Destination, EmphElem, LinkElem, LinkMarker, StrongElem, +}; use crate::text::UnderlineElem; /// A piece of document content. diff --git a/crates/typst-library/src/foundations/datetime.rs b/crates/typst-library/src/foundations/datetime.rs index cb9e09dee8ca..96f8b1ab7db2 100644 --- a/crates/typst-library/src/foundations/datetime.rs +++ b/crates/typst-library/src/foundations/datetime.rs @@ -141,7 +141,7 @@ use crate::foundations::{ /// components such as `hour` or `minute`, which would only work on datetimes /// that have a specified time. #[ty(scope, cast, since = "0.5.0")] -#[derive(Debug, Copy, Clone, PartialEq, Hash)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] pub enum Datetime { /// Representation as a date. Date(time::Date), diff --git a/crates/typst-library/src/foundations/scope.rs b/crates/typst-library/src/foundations/scope.rs index b17d9d60666d..397cd206dec9 100644 --- a/crates/typst-library/src/foundations/scope.rs +++ b/crates/typst-library/src/foundations/scope.rs @@ -217,6 +217,42 @@ impl Scope { binding.init_info().category = self.category; self.bind(name.into(), binding) } + + /// Prelude a binding from a path. + /// + /// The path may not contain multiple bindings with different feature gates, + /// otherwise this function will panic. + pub(crate) fn prelude_path<'a, const N: usize>( + &'a mut self, + path: [&'static str; N], + ) -> Option<&'a mut Binding> { + let [first, path @ .., last] = path.as_slice() else { + panic!("prelude path must have at least two segments"); + }; + + let mut binding = self.get(first)?; + let mut path_feature = binding.feature(); + + for name in path.iter().chain([last]) { + binding = binding.value.scope()?.get(name)?; + + if let Some(new) = binding.feature() + && let Some(prev) = path_feature.replace(new) + && prev != new + { + panic!( + "cannot prelude from path {path:?} because it's \ + gated by two different features: `{prev}` and `{new}`" + ); + } + } + + let mut binding = binding.clone(); + if let Some(feature) = path_feature { + binding.with_feature(feature); + } + Some(self.bind((*last).into(), binding)) + } } /// Scope manipulation and access. diff --git a/crates/typst-library/src/foundations/styles.rs b/crates/typst-library/src/foundations/styles.rs index 5755dcae4c11..2e2420d663dc 100644 --- a/crates/typst-library/src/foundations/styles.rs +++ b/crates/typst-library/src/foundations/styles.rs @@ -45,6 +45,14 @@ impl Styles { self.0.as_slice() } + /// Whether there is a style for the given field of the given element. + pub fn has(&self, _: Field) -> bool { + let elem = E::ELEM; + self.iter() + .filter_map(|style| style.property()) + .any(|property| property.is(elem, I)) + } + /// Set an inner value for a style property. /// /// If the property needs folding and the value is already contained in the @@ -638,7 +646,7 @@ impl<'a> StyleChain<'a> { let elem = E::ELEM; self.entries() .filter_map(|style| style.property()) - .any(|property| property.is_of(elem) && property.id == I) + .any(|property| property.is(elem, I)) } /// Retrieves a reference to a field, also taking into account the @@ -1024,7 +1032,6 @@ impl NativeRuleMap { } for target in [Target::Paged, Target::Html] { - rules.register(target, crate::model::ASSET_UNSUPPORTED_RULE); rules.register(target, crate::model::DOCUMENT_UNSUPPORTED_RULE); } diff --git a/crates/typst-library/src/foundations/target.rs b/crates/typst-library/src/foundations/target.rs index 1187d30d059a..871c512f8b45 100644 --- a/crates/typst-library/src/foundations/target.rs +++ b/crates/typst-library/src/foundations/target.rs @@ -93,10 +93,10 @@ pub struct TargetElem { /// Returns the current export target. /// /// This function returns -/// - `{"paged"}` in @pdf[PDF], @reference:png[PNG], and -/// @reference:svg[SVG] export, or within an @html.frame[HTML frame] +/// - `{"paged"}` in @pdf[PDF], @format.png[PNG], and +/// @format.svg[SVG] export, or within an @html.frame[HTML frame] /// - `{"html"}` in @html[HTML] export -/// - `{"bundle"}` in @reference:bundle[Bundle] export +/// - `{"bundle"}` in @format.bundle[Bundle] export /// /// = When to use it /// This function allows you to format your document properly across the paged, diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index 13c3d5dd9778..ddf3078828ec 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -20,7 +20,7 @@ use typst_library::visualize::{Paint, Stroke}; use typst_syntax::Span; use typst_utils::{NonZeroExt, SmallBitSet}; -use crate::pdf::{TableCellKind, TableHeaderScope}; +use crate::model::{TableCellKind, TableHeaderScope}; /// Convert a grid to a cell grid. #[typst_macros::time(span = elem.span())] diff --git a/crates/typst-library/src/layout/page.rs b/crates/typst-library/src/layout/page.rs index 01b58974594a..a7c0c97900fa 100644 --- a/crates/typst-library/src/layout/page.rs +++ b/crates/typst-library/src/layout/page.rs @@ -1,14 +1,14 @@ use std::num::NonZeroUsize; -use std::ops::RangeInclusive; use std::str::FromStr; +use ecow::{EcoVec, eco_vec}; use typst_utils::{NonZeroExt, Scalar, singleton}; use crate::diag::{HintedStrResult, SourceResult, bail}; use crate::engine::Engine; use crate::foundations::{ - Args, Cast, CastInfo, Construct, Content, Dict, Fold, FromValue, IntoValue, - NativeElement, Reflect, Set, Smart, Value, cast, elem, + Args, Array, Cast, CastInfo, Construct, Content, Dict, Fold, FromValue, IntoValue, + NativeElement, Reflect, Set, Smart, Value, cast, dict, elem, }; use crate::layout::{ Abs, Alignment, FlushElem, HAlignment, Length, OuterVAlignment, Ratio, Rel, Sides, @@ -758,18 +758,22 @@ cast! { } /// A list of page ranges to be exported. -#[derive(Debug, Clone, Hash)] -pub struct PageRanges(Vec); +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct PageRanges(EcoVec); -/// A range of pages to export. -/// -/// The range is one-indexed. For example, `1..=3` indicates the first, second -/// and third pages should be exported. -pub type PageRange = RangeInclusive>; +cast! { + PageRanges, + self => Value::Array(self.0.iter().copied().map(IntoValue::into_value).collect()), + range: PageRange => Self(eco_vec![range]), + array: Array => Self(array + .into_iter() + .map(Value::cast) + .collect::>()?), +} impl PageRanges { /// Create new page ranges. - pub fn new(ranges: Vec) -> Self { + pub fn new(ranges: EcoVec) -> Self { Self(ranges) } @@ -777,7 +781,12 @@ impl PageRanges { /// document while restricting the exported pages to these page ranges. /// This is the one-indexed version of [`Self::includes_page_index`]. pub fn includes_page(&self, page: NonZeroUsize) -> bool { - self.includes_page_index(page.get() - 1) + self.0.iter().any(|range| match (range.start, range.end) { + (Some(start), Some(end)) => (start..=end).contains(&page), + (Some(start), None) => (start..).contains(&page), + (None, Some(end)) => (..=end).contains(&page), + (None, None) => true, + }) } /// Check if a page, given its index, should be included when exporting the @@ -785,12 +794,77 @@ impl PageRanges { /// This is the zero-indexed version of [`Self::includes_page`]. pub fn includes_page_index(&self, page: usize) -> bool { let page = NonZeroUsize::try_from(page + 1).unwrap(); - self.0.iter().any(|range| match (range.start(), range.end()) { - (Some(start), Some(end)) => (start..=end).contains(&&page), - (Some(start), None) => (start..).contains(&&page), - (None, Some(end)) => (..=end).contains(&&page), - (None, None) => true, + self.includes_page(page) + } +} + +/// A range of pages to export. +/// +/// The range is one-indexed. For example, `1..=3` indicates the first, second +/// and third pages should be exported. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct PageRange { + /// An inclusive start bound. + pub start: Option, + /// An inclusive end bound. + pub end: Option, +} + +cast! { + PageRange, + self => { + Value::Dict(dict! { + "from" => self.start.into_value(), + "to" => self.end.into_value(), }) + }, + nr: i64 => Self::at(parse_page_number(nr)?), + mut dict: Dict => { + let parse_nr = |val: Value| parse_page_number(val.cast::()?); + let start = dict.take("from").ok().map(parse_nr).transpose()?; + let end = dict.take("to").ok().map(parse_nr).transpose()?; + dict.finish(&["from", "to"])?; + if start.is_none() && end.is_none() { + bail!("page range must not be empty"); + } + if let Some((start, end)) = start.zip(end) + && start > end + { + bail!("start of page range cannot be larger than end"); + } + Self::new(start, end) + } +} + +fn parse_page_number(nr: i64) -> HintedStrResult { + if nr < 1 { + bail!("page numbers start at one"); + } + Ok(usize::try_from(nr) + .ok() + .and_then(NonZeroUsize::new) + .ok_or("not a valid page number")?) +} + +impl PageRange { + /// Constructs a page range with optional start and end bounds. + pub fn new(start: Option, end: Option) -> Self { + Self { start, end } + } + + /// Constructs a page range with one page. + pub fn at(nr: NonZeroUsize) -> Self { + Self { start: Some(nr), end: Some(nr) } + } + + /// Constructs a page range without a start bound. + pub fn from(start: NonZeroUsize) -> Self { + Self { start: Some(start), end: None } + } + + /// Constructs a page range without an end bound. + pub fn to(end: NonZeroUsize) -> Self { + Self { start: None, end: Some(end) } } } diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs index 992fe4ab5a07..a3cc9bf93aae 100644 --- a/crates/typst-library/src/lib.rs +++ b/crates/typst-library/src/lib.rs @@ -14,13 +14,13 @@ extern crate self as typst_library; pub mod diag; pub mod engine; +pub mod format; pub mod foundations; pub mod introspection; pub mod layout; pub mod loading; pub mod math; pub mod model; -pub mod pdf; pub mod routines; pub mod symbols; pub mod text; @@ -35,6 +35,7 @@ use typst_syntax::{DiagSpan, DiagSpanKind, FileId, Source}; use typst_utils::{LazyHash, SmallBitSet}; use crate::diag::FileResult; +use crate::format::Format; use crate::foundations::{ Array, Binding, Bytes, Datetime, Dict, Duration, Module, NativeRuleMap, Scope, Styles, }; @@ -182,6 +183,8 @@ pub struct Library { pub std: Binding, /// In-development features that were enabled. pub features: Features, + /// Registered [export formats](crate::format). + pub formats: Vec, } /// Configurable builder for the standard library. @@ -192,16 +195,21 @@ pub struct LibraryBuilder { routines: &'static Routines, inputs: Option, features: Features, + formats: Vec, } impl LibraryBuilder { /// Creates a new builder. #[doc(hidden)] - pub fn from_routines(routines: &'static Routines) -> Self { + pub fn from_routines( + routines: &'static Routines, + formats: impl IntoIterator, + ) -> Self { Self { routines, inputs: None, features: Features::default(), + formats: formats.into_iter().collect(), } } @@ -223,15 +231,20 @@ impl LibraryBuilder { pub fn build(self) -> Library { let math = math::module(); let inputs = self.inputs.unwrap_or_default(); - let global = global(self.routines, math.clone(), inputs); + let global = global(&self.formats, math.clone(), inputs); + let mut rules = (self.routines.rules)(); + for format in &self.formats { + format.register_rules(&mut rules); + } Library { routines: self.routines, global: global.clone(), math, styles: Styles::new(), - rules: (self.routines.rules)(), + rules, std: Binding::detached(global), features: self.features, + formats: self.formats, } } } @@ -321,6 +334,7 @@ pub enum Category { Symbols, Text, Visualize, + Format, Pdf, Html, Svg, @@ -341,6 +355,7 @@ impl Category { Self::Symbols => "symbols", Self::Text => "text", Self::Visualize => "visualize", + Self::Format => "format", Self::Pdf => "pdf", Self::Html => "html", Self::Svg => "svg", @@ -351,7 +366,7 @@ impl Category { } /// Construct the module with global definitions. -fn global(routines: &Routines, math: Module, inputs: Dict) -> Module { +fn global(formats: &[Format], math: Module, inputs: Dict) -> Module { let mut global = Scope::deduplicating(); self::foundations::define(&mut global, inputs); @@ -362,12 +377,8 @@ fn global(routines: &Routines, math: Module, inputs: Dict) -> Module { self::introspection::define(&mut global); self::loading::define(&mut global); self::symbols::define(&mut global); - + global.define("format", self::format::module(formats)); global.define("math", math); - global.define("pdf", self::pdf::module()); - global - .define("html", (routines.html_module)()) - .with_feature(Feature::Html); prelude(&mut global); @@ -412,4 +423,9 @@ fn prelude(global: &mut Scope) { global.define("top", Alignment::TOP); global.define("horizon", Alignment::HORIZON); global.define("bottom", Alignment::BOTTOM); + + // If available, add some items to the global prelude. + global.prelude_path(["format", "html"]); + global.prelude_path(["format", "pdf"]); + global.prelude_path(["format", "bundle", "asset"]); } diff --git a/crates/typst-library/src/model/accessibility.rs b/crates/typst-library/src/model/accessibility.rs new file mode 100644 index 000000000000..80602e3e6aa9 --- /dev/null +++ b/crates/typst-library/src/model/accessibility.rs @@ -0,0 +1,149 @@ +use typst_macros::{Cast, elem}; + +use crate::diag::SourceResult; +use crate::diag::bail; +use crate::engine::Engine; +use crate::foundations::{Args, Construct, Content, NativeElement}; + +/// Marks content as a PDF artifact. +/// +/// Artifacts are parts of the document that are not meant to be read by +/// Assistive Technology (AT), such as screen readers. Typical examples include +/// purely decorative images that do not contribute to the meaning of the +/// document, watermarks, or repeated content such as page numbers. +/// +/// Typst will automatically mark certain content, such as page headers, +/// footers, backgrounds, and foregrounds, as artifacts. Likewise, paths and +/// shapes are automatically marked as artifacts, but their content is not. Line +/// numbers created using @par.line are automatically marked as artifacts, as +/// are repetitions of table headers and footers. +/// +/// Once something is marked as an artifact, you cannot make any of its contents +/// accessible again. If you need to mark only part of something as an artifact, +/// you may need to use this function multiple times. +/// +/// If you are unsure what constitutes an artifact, check the +/// @guides:accessibility:artifacts[Accessibility Guide]. +/// +/// In the future, this function may be moved out of the `pdf` module, making it +/// possible to hide content in HTML export from AT. +// TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`? +#[elem(since = "0.14.0", Tagged)] +pub struct ArtifactElem { + /// The artifact kind. + /// + /// You can improve accessibility by using the most specific artifact kind + /// available. Your choice will govern how the PDF reader treats the + /// artifact during reflow and content extraction (e.g. copy and paste). + /// + /// Artifact types have been introduced in various different PDF + /// specifications. Depending on which PDF version you target, Typst will + /// select the most appropriate artifact type using your selection here. + #[default(ArtifactKind::Other)] + pub kind: ArtifactKind, + + /// The content that is an artifact. + #[required] + pub body: Content, +} + +/// The type of artifact. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum ArtifactKind { + /// Repeats on the top of each page. + Header, + /// Repeats at the bottom of each page. + Footer, + /// Text or graphics in the back- or foreground of all pages. + Watermark, + /// Page numbers. + PageNumber, + /// Line or paragraph numbers. + LineNumber, + /// Placeholders for areas in which there was content in another rendition + /// of the document which has since been removed. + Redaction, + /// Bates numbering. + Bates, + /// Not part of the document, but rather the page it is printed on. An + /// example would be cut marks or color bars. + Page, + /// Artifacts arising from paginating the document not covered by other + /// artifact types. This category generally applies if this artifact would + /// not appear in your document if it was a website instead. If your + /// artifact is covered by other categories, prefer them over this. + PaginationOther, + /// Purely cosmetric content or typographical flourishes not contributing to + /// the document's content. + Layout, + /// Background of a page or a graphical element. This artifact kind was + /// added in PDF 1.7. However, due to requirements in the PDF 1.7 + /// specification that later specifications lifted, Typst only uses this + /// artifact type in PDF 2.0. If you use it in a PDF 1.7 or earlier, Typst + /// will use the `{"other"}` type instead. + Background, + /// Other artifacts. + #[default] + Other, +} + +/// Used to delimit content for tagged PDF. +#[elem(Construct, Tagged)] +pub struct PdfMarkerTag { + #[internal] + #[required] + pub kind: PdfMarkerTagKind, + #[required] + pub body: Content, +} + +impl Construct for PdfMarkerTag { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually"); + } +} + +macro_rules! pdf_marker_tag { + ($(#[doc = $doc:expr] $variant:ident$(($($name:ident: $ty:ty)+))?,)+) => { + #[derive(Debug, Clone, Eq, PartialEq, Hash)] + pub enum PdfMarkerTagKind { + $( + #[doc = $doc] + $variant $(($($ty),+))? + ),+ + } + + impl PdfMarkerTag { + $( + #[doc = $doc] + #[expect(non_snake_case)] + pub fn $variant($($($name: $ty,)+)? body: Content) -> Content { + let span = body.span(); + Self { + kind: PdfMarkerTagKind::$variant $(($($name),+))?, + body, + }.pack().spanned(span) + } + )+ + } + } +} + +pdf_marker_tag! { + /// `TOC`. + OutlineBody, + /// `L` bibliography list. + Bibliography(numbered: bool), + /// `LBody` wrapping `BibEntry`. + BibEntry, + /// `Lbl` (marker) of the list item. + ListItemLabel, + /// `LBody` of the list item. + ListItemBody, + /// `Lbl` of the term item. + TermsItemLabel, + /// `LBody` the term item including the label. + TermsItemBody, + /// A generic `Lbl`. + Label, +} diff --git a/crates/typst-library/src/model/asset.rs b/crates/typst-library/src/model/asset.rs deleted file mode 100644 index 0a6a833c4e0a..000000000000 --- a/crates/typst-library/src/model/asset.rs +++ /dev/null @@ -1,86 +0,0 @@ -use typst_macros::elem; - -use crate::diag::bail; -use crate::foundations::{BundlePath, Bytes, ShowFn, Str, cast}; - -/// Adds a custom file to a bundle. -/// -/// This function creates a single file in a @reference:bundle[bundle], from -/// @bytes[raw byte data]. Unlike @document[documents], assets will be emitted -/// as-is without undergoing compilation. -/// -/// The `asset` function can be combined with @read to copy a file from the -/// project into the output bundle. The first argument to `asset` defines the -/// output path for the asset in the bundle, while the path passed to `read` -/// defines where in the project to read the data from. -/// -/// ```typ -/// // Copy the file `styles.css` into the bundle. -/// #asset("styles.css", read("styles.css")) -/// ``` -/// -/// That said, `asset` is not tied to `read`. You can also generate bytes -/// directly or use a function like @json.encode to emit serialized data. -/// -/// ```typ -/// // Emits a JSON file with the number -/// // of headings in the document. -/// #context { -/// let headings = query(heading) -/// let meta = ( -/// count: headings.len(), -/// ) -/// asset("meta.json", json.encode(meta)) -/// } -/// -/// #document("doc.pdf")[ -/// = Introduction -/// = Conclusion -/// ] -/// ``` -/// -/// This would emit a `meta.json` file with the following contents into the -/// resulting bundle: -/// -/// ```json -/// { -/// "count": 2 -/// } -/// ``` -/// -/// This function may only be used in the @reference:bundle[bundle] target. -#[elem(since = "0.15.0", Locatable)] -pub struct AssetElem { - /// The path in the bundle at which the asset will be placed. - /// - /// May contain interior slashes, in which case intermediate directories - /// will be automatically created. - #[required] - pub path: BundlePath, - - /// The raw data that will be written into the file at the specified path. - /// - /// If a string is given, it will be encoded using UTF-8. - #[required] - pub data: AssetData, -} - -pub const ASSET_UNSUPPORTED_RULE: ShowFn = |elem, _, _| { - bail!( - elem.span(), - "assets are only supported in the bundle target"; - // TODO: Support for CLI-specific hints would be nice. - hint: "try enabling the bundle target"; - ) -}; - -/// The raw data for an asset. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct AssetData(pub Bytes); - -cast! { - AssetData, - self => self.0.into_value(), - v: Str => Self(Bytes::from_string(v)), - v: Bytes => Self(v), -} diff --git a/crates/typst-library/src/model/document.rs b/crates/typst-library/src/model/document.rs index fec7ddbdbf61..71d6064d82ca 100644 --- a/crates/typst-library/src/model/document.rs +++ b/crates/typst-library/src/model/document.rs @@ -2,6 +2,7 @@ use ecow::EcoString; use typst_syntax::VirtualPath; use crate::diag::{HintedStrResult, bail, error}; +use crate::format::DocumentFormatOptions; use crate::foundations::{ Array, BundlePath, Cast, Content, Datetime, OneOrMultiple, Packed, ShowFn, ShowSet, Smart, StyleChain, Styles, Target, Value, cast, elem, @@ -70,7 +71,7 @@ use crate::text::{Locale, TextElem}; /// - SVG and PNG export do not have any metadata support at all. /// /// = Documents in bundle export -/// In @reference:bundle[bundle export], a document element represents a single +/// In @format.bundle[bundle export], a document element represents a single /// file in the bundle output, in one of Typst's other export formats. When /// creating a document, you must provide an output path and some content. Typst /// will compile and export the provided content with the appropriate format. By @@ -122,6 +123,26 @@ use crate::text::{Locale, TextElem}; /// #context document.title /// ] /// ``` +/// +/// == Export settings +/// Just like with document metadata, you can use set rules on @format[format +/// elements] to configure export settings for a particular document. +/// +/// ```typ +/// // Pretty-print all HTML. +/// #set format.html(pretty: true) +/// +/// #document("index.html")[ +/// ... +/// ] +/// +/// #document("archive.pdf")[ +/// // And use a specific PDF standard +/// // for `archive.pdf`. +/// #set pdf(standard: "a-2a") +/// ... +/// ] +/// ``` #[elem(since = "forever", Locatable, ShowSet)] pub struct DocumentElem { /// The path in the bundle at which the exported document will be placed. @@ -129,7 +150,7 @@ pub struct DocumentElem { /// May contain interior slashes, in which case intermediate directories /// will be automatically created. /// - /// This property is only supported in the @reference:bundle[bundle] target. + /// This property is only supported in the @format.bundle[bundle] target. #[required] pub path: BundlePath, @@ -138,7 +159,7 @@ pub struct DocumentElem { /// If `{auto}`, Typst attempts to infer the export format from the /// @document.path[`path`'s] file extension. /// - /// This property is only supported in the @reference:bundle[bundle] target. + /// This property is only supported in the @format.bundle[bundle] target. pub format: Smart, /// The document's title. This is rendered as the title of the PDF viewer @@ -180,7 +201,7 @@ pub struct DocumentElem { /// The content that makes up the document. /// - /// This property is only supported in the @reference:bundle[bundle] target. + /// This property is only supported in the @format.bundle[bundle] target. #[required] pub body: Content, } @@ -219,10 +240,8 @@ fn determine_format_from_path(path: &VirtualPath) -> Option { pub const DOCUMENT_UNSUPPORTED_RULE: ShowFn = |elem, _, _| { bail!( elem.span(), - "constructing a document is only supported in the bundle target"; - // TODO: Support for CLI-specific hints would be nice. - hint: "try enabling the bundle target"; - hint: "or use a `set document(..)` rule to configure metadata"; + "cannot construct a document"; + hint: "use a `set document(..)` rule to configure metadata"; ) }; @@ -323,6 +342,9 @@ cast! { pub trait Document { /// Get the document's metadata. fn info(&self) -> &DocumentInfo; + + /// Get the format options set by the document. + fn options(&self) -> &DocumentFormatOptions; } /// Details about the document. diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs index e3e21cc1c9ea..ca4118994971 100644 --- a/crates/typst-library/src/model/link.rs +++ b/crates/typst-library/src/model/link.rs @@ -121,7 +121,7 @@ use crate::text::{LocalName, TextElem}; /// generated. /// /// = Links in bundle export -/// In @reference:bundle[bundle export], linking still works as usual. For +/// In @format.bundle[bundle export], linking still works as usual. For /// instance, if you attach a label to an element in one document, links in /// other documents can reference that label. In addition, documents and assets /// are also directly linkable. To link to a full document or asset, you can diff --git a/crates/typst-library/src/model/mod.rs b/crates/typst-library/src/model/mod.rs index 459ec4c5331f..eef3a92ccad6 100644 --- a/crates/typst-library/src/model/mod.rs +++ b/crates/typst-library/src/model/mod.rs @@ -1,6 +1,6 @@ //! Structuring elements that define the document model. -mod asset; +mod accessibility; mod bibliography; mod cite; mod divider; @@ -24,7 +24,7 @@ mod table; mod terms; mod title; -pub use self::asset::*; +pub use self::accessibility::*; pub use self::bibliography::*; pub use self::cite::*; pub use self::divider::*; @@ -46,14 +46,12 @@ pub use self::table::*; pub use self::terms::*; pub use self::title::*; -use crate::Feature; use crate::foundations::Scope; /// Hook up all `model` definitions. pub fn define(global: &mut Scope) { global.start_category(crate::Category::Model); global.define_elem::(); - global.define_elem::().with_feature(Feature::Bundle); global.define_elem::(); global.define_elem::(); global.define_elem::(); diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index 8aceddb34a8e..357697122d17 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -20,8 +20,8 @@ use crate::layout::{ Abs, Axes, BlockBody, BlockElem, BoxElem, Dir, Em, Fr, HElem, Length, Region, Rel, RepeatElem, Sides, }; +use crate::model::PdfMarkerTag; use crate::model::{HeadingElem, NumberingPattern, ParElem, Refable}; -use crate::pdf::PdfMarkerTag; use crate::text::{LocalName, SpaceElem, TextElem}; /// A table of contents, figures, or other elements. diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 7b12f81f242c..a08e132cb7e7 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -2,6 +2,7 @@ use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; use ecow::EcoString; +use typst_macros::Cast; use typst_utils::NonZeroExt; use crate::diag::{HintedStrResult, HintedString, SourceResult, bail}; @@ -15,7 +16,6 @@ use crate::layout::{ Length, OuterHAlignment, OuterVAlignment, Rel, Sides, TrackSizings, }; use crate::model::Figurable; -use crate::pdf::TableCellKind; use crate::text::LocalName; use crate::visualize::{Paint, Stroke}; @@ -455,7 +455,7 @@ impl TryFrom for TableItem { /// header cell. Likewise, you can use @pdf.data-cell to mark cells in this /// function as data cells. Note that these functions are not final and thus /// only available when you enable the `a11y-extras` feature (see the -/// @pdf[PDF module documentation] for details). +/// @pdf[PDF format documentation] for details). /// /// ```example /// #set page(height: 11.5em) @@ -801,3 +801,45 @@ impl From for TableCell { value.unpack::().unwrap_or_else(Self::new) } } + +/// Describes what kind of table cell this is. +/// +/// The full Typst table model only supports header and footer rows, this is +/// currently only used for PDF export. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub enum TableCellKind { + Header(NonZeroU32, TableHeaderScope), + Footer, + #[default] + Data, +} + +/// Which table track a header cell labels. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum TableHeaderScope { + /// The header cell refers to both the row and the column. + Both, + /// The header cell refers to the column. + #[default] + Column, + /// The header cell refers to the row. + Row, +} + +impl TableHeaderScope { + pub fn refers_to_column(&self) -> bool { + match self { + TableHeaderScope::Both => true, + TableHeaderScope::Column => true, + TableHeaderScope::Row => false, + } + } + + pub fn refers_to_row(&self) -> bool { + match self { + TableHeaderScope::Both => true, + TableHeaderScope::Column => false, + TableHeaderScope::Row => true, + } + } +} diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs deleted file mode 100644 index 09c6766dff15..000000000000 --- a/crates/typst-library/src/pdf/accessibility.rs +++ /dev/null @@ -1,371 +0,0 @@ -use std::num::NonZeroU32; - -use ecow::EcoString; -use typst_macros::{Cast, elem, func}; -use typst_utils::NonZeroExt; - -use crate::diag::SourceResult; -use crate::diag::bail; -use crate::engine::Engine; -use crate::foundations::{Args, Construct, Content, NativeElement, Smart}; -use crate::model::{TableCell, TableElem}; - -/// Marks content as a PDF artifact. -/// -/// Artifacts are parts of the document that are not meant to be read by -/// Assistive Technology (AT), such as screen readers. Typical examples include -/// purely decorative images that do not contribute to the meaning of the -/// document, watermarks, or repeated content such as page numbers. -/// -/// Typst will automatically mark certain content, such as page headers, -/// footers, backgrounds, and foregrounds, as artifacts. Likewise, paths and -/// shapes are automatically marked as artifacts, but their content is not. Line -/// numbers created using @par.line are automatically marked as artifacts, as -/// are repetitions of table headers and footers. -/// -/// Once something is marked as an artifact, you cannot make any of its contents -/// accessible again. If you need to mark only part of something as an artifact, -/// you may need to use this function multiple times. -/// -/// If you are unsure what constitutes an artifact, check the -/// @guides:accessibility:artifacts[Accessibility Guide]. -/// -/// In the future, this function may be moved out of the `pdf` module, making it -/// possible to hide content in HTML export from AT. -// TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`? -#[elem(since = "0.14.0", Tagged)] -pub struct ArtifactElem { - /// The artifact kind. - /// - /// You can improve accessibility by using the most specific artifact kind - /// available. Your choice will govern how the PDF reader treats the - /// artifact during reflow and content extraction (e.g. copy and paste). - /// - /// Artifact types have been introduced in various different PDF - /// specifications. Depending on which PDF version you target, Typst will - /// select the most appropriate artifact type using your selection here. - #[default(ArtifactKind::Other)] - pub kind: ArtifactKind, - - /// The content that is an artifact. - #[required] - pub body: Content, -} - -/// The type of artifact. -#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] -pub enum ArtifactKind { - /// Repeats on the top of each page. - Header, - /// Repeats at the bottom of each page. - Footer, - /// Text or graphics in the back- or foreground of all pages. - Watermark, - /// Page numbers. - PageNumber, - /// Line or paragraph numbers. - LineNumber, - /// Placeholders for areas in which there was content in another rendition - /// of the document which has since been removed. - Redaction, - /// Bates numbering. - Bates, - /// Not part of the document, but rather the page it is printed on. An - /// example would be cut marks or color bars. - Page, - /// Artifacts arising from paginating the document not covered by other - /// artifact types. This category generally applies if this artifact would - /// not appear in your document if it was a website instead. If your - /// artifact is covered by other categories, prefer them over this. - PaginationOther, - /// Purely cosmetric content or typographical flourishes not contributing to - /// the document's content. - Layout, - /// Background of a page or a graphical element. This artifact kind was - /// added in PDF 1.7. However, due to requirements in the PDF 1.7 - /// specification that later specifications lifted, Typst only uses this - /// artifact type in PDF 2.0. If you use it in a PDF 1.7 or earlier, Typst - /// will use the `{"other"}` type instead. - Background, - /// Other artifacts. - #[default] - Other, -} - -/// A summary of the purpose and structure of a complex table. -/// -/// This will be available for Assistive Technology (AT), such as screen -/// readers, when exporting to PDF, but not for sighted readers of your file. -/// -/// This field is intended for instructions that help the user navigate the -/// table using AT. It is not an alternative description, so do not duplicate -/// the contents of the table within. Likewise, do not use this for the core -/// takeaway of the table. Instead, include that in the text around the table -/// or, even better, in a @figure.caption[figure caption]. -/// -/// If in doubt whether your table is complex enough to warrant a summary, err -/// on the side of not including one. If you are certain that your table is -/// complex enough, consider whether a sighted user might find it challenging. -/// They might benefit from the instructions you put here, so consider printing -/// them visibly in the document instead. -/// -/// The API of this feature is temporary. Hence, calling this function requires -/// enabling the `a11y-extras` feature flag at the moment. Even if this -/// functionality should be available without a feature flag in the future, the -/// summary will remain exclusive to PDF export. -/// -/// ```example -/// #figure( -/// pdf.table-summary( -/// // The summary just provides orientation and structural -/// // information for AT users. -/// summary: "The first two columns list the names of each participant. The last column contains cells spanning multiple rows for their assigned group.", -/// table( -/// columns: 3, -/// table.header[First Name][Given Name][Group], -/// [Mike], [Davis], table.cell(rowspan: 3)[Sales], -/// [Anna], [Smith], -/// [John], [Johnson], -/// [Sara], [Wilkins], table.cell(rowspan: 2)[Operations], -/// [Tom], [Brown], -/// ), -/// ), -/// // This is the key takeaway of the table, so we put it in the caption. -/// caption: [The Sales org now has a new member], -/// ) -/// ``` -#[func(since = "0.14.0")] -pub fn table_summary( - #[named] summary: Option, - /// The table. - table: TableElem, -) -> Content { - table.with_summary(summary).pack() -} - -/// Explicitly defines a cell as a header cell. -/// -/// Header cells help users of Assistive Technology (AT) understand and navigate -/// complex tables. When your table is correctly marked up with header cells, AT -/// can announce the relevant header information on-demand when entering a cell. -/// -/// By default, Typst will automatically mark all cells within @table.header as -/// header cells. They will apply to the columns below them. You can use that -/// function's @table.header.level[`level`] parameter to make header cells -/// labelled by other header cells. -/// -/// The `pdf.header-cell` function allows you to indicate that a cell is a -/// header cell in the following additional situations: -/// -/// - You have a *header column* in which each cell applies to its row. In that -/// case, you pass `{"row"}` as an argument to the -/// @pdf.header-cell.scope[`scope` parameter] to indicate that the header cell -/// applies to the row. -/// - You have a cell in @table.header, for example at the very start, that -/// labels both its row and column. In that case, you pass `{"both"}` as an -/// argument to the @pdf.header-cell.scope[`scope`] parameter. -/// - You have a header cell in a row not containing other header cells. In that -/// case, you can use this function to mark it as a header cell. -/// -/// The API of this feature is temporary. Hence, calling this function requires -/// enabling the `a11y-extras` feature flag at the moment. In a future Typst -/// release, this functionality may move out of the `pdf` module so that tables -/// in other export targets can contain the same information. -/// -/// ```example -/// >>> #set text(font: "IBM Plex Sans") -/// #show table.cell.where(x: 0): set text(weight: "medium") -/// #show table.cell.where(y: 0): set text(weight: "bold") -/// -/// #table( -/// columns: 3, -/// align: (start, end, end), -/// -/// table.header( -/// // Top-left cell: Labels both the nutrient rows -/// // and the serving size columns. -/// pdf.header-cell(scope: "both")[Nutrient], -/// [Per 100g], -/// [Per Serving], -/// ), -/// -/// // First column cells are row headers -/// pdf.header-cell(scope: "row")[Calories], -/// [250 kcal], [375 kcal], -/// pdf.header-cell(scope: "row")[Protein], -/// [8g], [12g], -/// pdf.header-cell(scope: "row")[Fat], -/// [12g], [18g], -/// pdf.header-cell(scope: "row")[Carbs], -/// [30g], [45g], -/// ) -/// ``` -#[func(since = "0.14.0")] -pub fn header_cell( - /// The nesting level of this header cell. - #[named] - #[default(NonZeroU32::ONE)] - level: NonZeroU32, - /// What track of the table this header cell applies to. - #[named] - #[default] - scope: TableHeaderScope, - /// The table cell. - /// - /// This can be content or a call to @table.cell. - cell: TableCell, -) -> Content { - cell.with_kind(Smart::Custom(TableCellKind::Header(level, scope))) - .pack() -} - -/// Explicitly defines this cell as a data cell. -/// -/// Each cell in a table is either a header cell or a data cell. By default, all -/// cells in @table.header are header cells, and all other cells data cells. -/// -/// If your header contains a cell that is not a header cell, you can use this -/// function to mark it as a data cell. -/// -/// The API of this feature is temporary. Hence, calling this function requires -/// enabling the `a11y-extras` feature flag at the moment. In a future Typst -/// release, this functionality may move out of the `pdf` module so that tables -/// in other export targets can contain the same information. -/// -/// ```example -/// #show table.cell.where(x: 0): set text(weight: "bold") -/// #show table.cell.where(x: 1): set text(style: "italic") -/// #show table.cell.where(x: 1, y: 0): set text(style: "normal") -/// -/// #table( -/// columns: 3, -/// align: (left, left, center), -/// -/// table.header[Objective][Key Result][Status], -/// -/// table.header( -/// level: 2, -/// table.cell(colspan: 2)[Improve Customer Satisfaction], -/// // Status is data for this objective, not a header -/// pdf.data-cell[✓ On Track], -/// ), -/// [], [Increase NPS to 50+], [45], -/// [], [Reduce churn to \<5%], [4.2%], -/// -/// table.header( -/// level: 2, -/// table.cell(colspan: 2)[Grow Revenue], -/// pdf.data-cell[⚠ At Risk], -/// ), -/// [], [Achieve \$2M ARR], [\$1.8M], -/// [], [Close 50 enterprise deals], [38], -/// ) -/// ``` -#[func(since = "0.14.0")] -pub fn data_cell( - /// The table cell. - /// - /// This can be content or a call to @table.cell. - cell: TableCell, -) -> Content { - cell.with_kind(Smart::Custom(TableCellKind::Data)).pack() -} - -#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] -pub enum TableCellKind { - Header(NonZeroU32, TableHeaderScope), - Footer, - #[default] - Data, -} - -/// Which table track a header cell labels. -#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] -pub enum TableHeaderScope { - /// The header cell refers to both the row and the column. - Both, - /// The header cell refers to the column. - #[default] - Column, - /// The header cell refers to the row. - Row, -} - -impl TableHeaderScope { - pub fn refers_to_column(&self) -> bool { - match self { - TableHeaderScope::Both => true, - TableHeaderScope::Column => true, - TableHeaderScope::Row => false, - } - } - - pub fn refers_to_row(&self) -> bool { - match self { - TableHeaderScope::Both => true, - TableHeaderScope::Column => false, - TableHeaderScope::Row => true, - } - } -} - -/// Used to delimit content for tagged PDF. -#[elem(Construct, Tagged)] -pub struct PdfMarkerTag { - #[internal] - #[required] - pub kind: PdfMarkerTagKind, - #[required] - pub body: Content, -} - -impl Construct for PdfMarkerTag { - fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { - bail!(args.span, "cannot be constructed manually"); - } -} - -macro_rules! pdf_marker_tag { - ($(#[doc = $doc:expr] $variant:ident$(($($name:ident: $ty:ty)+))?,)+) => { - #[derive(Debug, Clone, Eq, PartialEq, Hash)] - pub enum PdfMarkerTagKind { - $( - #[doc = $doc] - $variant $(($($ty),+))? - ),+ - } - - impl PdfMarkerTag { - $( - #[doc = $doc] - #[expect(non_snake_case)] - pub fn $variant($($($name: $ty,)+)? body: Content) -> Content { - let span = body.span(); - Self { - kind: PdfMarkerTagKind::$variant $(($($name),+))?, - body, - }.pack().spanned(span) - } - )+ - } - } -} - -pdf_marker_tag! { - /// `TOC`. - OutlineBody, - /// `L` bibliography list. - Bibliography(numbered: bool), - /// `LBody` wrapping `BibEntry`. - BibEntry, - /// `Lbl` (marker) of the list item. - ListItemLabel, - /// `LBody` of the list item. - ListItemBody, - /// `Lbl` of the term item. - TermsItemLabel, - /// `LBody` the term item including the label. - TermsItemBody, - /// A generic `Lbl`. - Label, -} diff --git a/crates/typst-library/src/pdf/attach.rs b/crates/typst-library/src/pdf/attach.rs deleted file mode 100644 index f49f1c267a4e..000000000000 --- a/crates/typst-library/src/pdf/attach.rs +++ /dev/null @@ -1,86 +0,0 @@ -use ecow::EcoString; -use typst_syntax::Spanned; - -use crate::World; -use crate::diag::At; -use crate::foundations::{Bytes, Cast, Derived, PathOrStr, elem}; - -/// A file that will be attached to the output PDF. -/// -/// This can be used to distribute additional files associated with the PDF -/// within it. PDF readers will display the files in a file listing. -/// -/// Some international standards use this mechanism to attach machine-readable -/// data (e.g., ZUGFeRD/Factur-X for invoices) that mirrors the visual content -/// of the PDF. -/// -/// = Example -/// ```typ -/// #pdf.attach( -/// "experiment.csv", -/// relationship: "supplement", -/// mime-type: "text/csv", -/// description: "Raw Oxygen readings from the Arctic experiment", -/// ) -/// ``` -/// -/// = Notes -/// - This element is ignored if exporting to a format other than PDF. -/// - File attachments are not currently supported for PDF/A-2, even if the -/// attached file conforms to PDF/A-1 or PDF/A-2. -#[elem(since = "0.14.0", keywords = ["embed"], Locatable)] -pub struct AttachElem { - /// The path of the file to be attached. - /// - /// Must always be specified, but is only read from if no data is provided - /// in the following argument. - #[required] - #[parse( - let Spanned { v: path, span } = - args.expect::>("path")?; - let resolved = path.resolve_if_some(span.id()).at(span)?; - // The derived part is the virtual-root-relative resolved path. - let derived = resolved.vpath().get_without_slash().into(); - Derived::new(path, derived) - )] - pub path: Derived, - - /// Raw file data, optionally. - /// - /// If omitted, the data is read from the specified path. - #[positional] - // Not actually required as an argument, but always present as a field. - // We can't distinguish between the two at the moment. - #[required] - #[parse( - match args.eat::()? { - Some(data) => data, - None => engine.world.file(resolved.intern()).at(span)?, - } - )] - pub data: Bytes, - - /// The relationship of the attached file to the document. - /// - /// Ignored if export doesn't target PDF/A-3. - pub relationship: Option, - - /// The MIME type of the attached file. - pub mime_type: Option, - - /// A description for the attached file. - pub description: Option, -} - -/// The relationship of an attached file with the document. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] -pub enum AttachedFileRelationship { - /// The PDF document was created from the source file. - Source, - /// The file was used to derive a visual presentation in the PDF. - Data, - /// An alternative representation of the document. - Alternative, - /// Additional resources for the document. - Supplement, -} diff --git a/crates/typst-library/src/pdf/mod.rs b/crates/typst-library/src/pdf/mod.rs deleted file mode 100644 index 86eb7ad1319f..000000000000 --- a/crates/typst-library/src/pdf/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! PDF-specific functionality. - -mod accessibility; -mod attach; - -pub use self::accessibility::*; -pub use self::attach::*; - -use crate::Feature; -use crate::foundations::{Module, Scope}; - -/// Hook up all `pdf` definitions. -pub fn module() -> Module { - let mut pdf = Scope::deduplicating(); - pdf.start_category(crate::Category::Pdf); - pdf.define_elem::(); - pdf.define_elem::(); - - pdf.define_func::().with_feature(Feature::A11yExtras); - pdf.define_func::().with_feature(Feature::A11yExtras); - pdf.define_func::().with_feature(Feature::A11yExtras); - - Module::new("pdf", pdf) -} diff --git a/crates/typst-library/src/routines.rs b/crates/typst-library/src/routines.rs index 63c46f7d9ef7..03c2efad6f70 100644 --- a/crates/typst-library/src/routines.rs +++ b/crates/typst-library/src/routines.rs @@ -7,9 +7,10 @@ use typst_utils::LazyHash; use crate::diag::SourceResult; use crate::engine::{Engine, Route, Sink, Traced}; +use crate::format::DocumentFormatOptions; use crate::foundations::{ - Args, Closure, Content, Context, Func, Module, NativeRuleMap, Scope, StyleChain, - Styles, Value, + Args, Closure, Content, Context, Func, NativeRuleMap, Scope, StyleChain, Styles, + Value, }; use crate::introspection::{Introspector, Locator, SplitLocator}; use crate::layout::{Frame, Region}; @@ -97,9 +98,6 @@ routines! { region: Region, ) -> SourceResult - /// Constructs the `html` module. - fn html_module() -> Module - /// Returns the body of a MathML `HtmlElem`, if the content is one. fn html_mathml_body<'a>( content: &'a Content, @@ -157,7 +155,7 @@ pub enum RealizationKind<'a> { Bundle, /// This the root realization for a document. Requires a mutable reference /// to document metadata that will be filled from `set document` rules. - Document { info: &'a mut DocumentInfo }, + Document { info: &'a mut DocumentInfo, options: &'a mut DocumentFormatOptions }, /// A nested realization in a container (e.g. a `block` or an `html.div`). /// Requires a mutable reference to an enum that will be set to /// `FragmentKind::Inline` if the fragment's content was fully inline. diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 68866c8acb52..07709d31bdb5 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -114,7 +114,7 @@ pub struct ImageElem { /// /// - When exporting to PDF, any PDF image file used must have a version /// equal to or lower than the - /// @pdf:pdf-versions[export target PDF version]. + /// @pdf.standard:pdf-versions[export target PDF version]. /// - PDF files as images are currently not supported when exporting with a /// specific PDF standard, like PDF/A-3 or PDF/UA-1. In these cases, you /// can instead use SVGs to embed vector images. diff --git a/crates/typst-macros/src/func.rs b/crates/typst-macros/src/func.rs index 4bfd9ef89c4f..e152ff0c9ec1 100644 --- a/crates/typst-macros/src/func.rs +++ b/crates/typst-macros/src/func.rs @@ -352,7 +352,7 @@ fn create_func_data(func: &Func) -> TokenStream { contextual: #contextual, scope: ::std::sync::LazyLock::new(&|| #scope), params: ::std::sync::LazyLock::new(&|| ::std::vec![#(#params),*]), - returns: ::std::sync::LazyLock::new(&|| <#returns as #foundations::Reflect>::output()), + returns: ::std::sync::LazyLock::new(&|| <#returns as #foundations::Reflect>::output()), } } } diff --git a/crates/typst-macros/src/scope.rs b/crates/typst-macros/src/scope.rs index d2a32c465839..e6cfed14df5b 100644 --- a/crates/typst-macros/src/scope.rs +++ b/crates/typst-macros/src/scope.rs @@ -5,7 +5,7 @@ use syn::parse::{Parse, ParseStream}; use syn::punctuated::Punctuated; use syn::{MetaNameValue, Result, Token, parse_quote}; -use crate::util::{BareType, foundations, kw, parse_flag}; +use crate::util::{BareType, foundations, kw, parse_flag, parse_ident}; /// Expand the `#[scope]` macro. pub fn scope(stream: TokenStream, item: syn::Item) -> Result { @@ -32,6 +32,7 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { let mut definitions = vec![]; let mut constructor = quote! { None }; + let mut custom_defs = None; for child in &mut item.items { let bare: BareType; let (mut def, attrs) = match child { @@ -45,6 +46,10 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { constructor = tokens; continue; } + FnKind::CustomDefs(tokens) => { + custom_defs = Some(tokens); + continue; + } }, &item.attrs, ), @@ -105,6 +110,10 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { Some(ident_ext) => rewrite_primitive_base(&item, ident_ext), }; + let category = meta.category.map(|category| { + quote! { scope.start_category(::typst_library::Category::#category); } + }); + Ok(quote! { #base @@ -116,7 +125,9 @@ pub fn scope(stream: TokenStream, item: syn::Item) -> Result { #[expect(deprecated)] fn scope() -> #foundations::Scope { let mut scope = #foundations::Scope::deduplicating(); + #category #(#definitions;)* + #custom_defs scope } } @@ -128,11 +139,16 @@ struct Meta { /// Whether this the scope should be implemented through an extension /// trait instead of an inherent impl. ext: bool, + /// The category of the scope. + category: Option, } impl Parse for Meta { fn parse(input: ParseStream) -> Result { - Ok(Self { ext: parse_flag::(input)? }) + Ok(Self { + ext: parse_flag::(input)?, + category: parse_ident::(input)?, + }) } } @@ -157,13 +173,41 @@ fn handle_type_or_elem(item: &BareType) -> Result { /// Process a function, return its definition, and register it as a constructor /// if applicable. fn handle_fn(self_ty: &syn::Type, item: &mut syn::ImplItemFn) -> Result { - let Some(attr) = item.attrs.iter_mut().find(|attr| attr.meta.path().is_ident("func")) - else { - bail!(item, "scope function is missing #[func] attribute"); - }; + let mut ret = Vec::new(); + item.attrs.retain_mut(|attr| { + if attr.meta.path().is_ident("func") { + let ident_data = quote::format_ident!("{}_data", item.sig.ident); + ret.push((attr.clone(), handle_fn_member(self_ty, ident_data, attr))); + // Retain, but possibly modify the `#[func]` attribute. + true + } else if attr.meta.path().is_ident("defs") { + let ident = &item.sig.ident; + let call = quote! { #self_ty::#ident(&mut scope); }; + ret.push((attr.clone(), Ok(FnKind::CustomDefs(call)))); + // Remove the `#[defs]` attribute. + false + } else { + true + } + }); - let ident_data = quote::format_ident!("{}_data", item.sig.ident); + if let [_, (attr, _), ..] = ret.as_slice() { + bail!( + attr, + "scope functions can only have a single #[func] or #[defs] attribute" + ); + } + match ret.pop() { + Some((_, res)) => res, + None => bail!(item, "scope function is missing #[func] or #[defs] attribute"), + } +} +fn handle_fn_member( + self_ty: &syn::Type, + ident_data: syn::Ident, + attr: &mut syn::Attribute, +) -> Result { match &mut attr.meta { syn::Meta::Path(_) => { *attr = parse_quote! { #[func(parent = #self_ty)] }; @@ -185,6 +229,7 @@ fn handle_fn(self_ty: &syn::Type, item: &mut syn::ImplItemFn) -> Result enum FnKind { Constructor(TokenStream), Member(TokenStream), + CustomDefs(TokenStream), } /// Rewrite an impl block for a primitive into a trait + trait impl. diff --git a/crates/typst-macros/src/util.rs b/crates/typst-macros/src/util.rs index a22327a9815a..a8f0871146a7 100644 --- a/crates/typst-macros/src/util.rs +++ b/crates/typst-macros/src/util.rs @@ -132,6 +132,13 @@ pub fn parse_key_value_array( Ok(parse_key_value::>(input)?.map_or(vec![], |array| array.0)) } +/// Parse an metadata key-identifier pair, separated by `=`. +pub fn parse_ident( + input: ParseStream, +) -> Result> { + parse_key_value::(input) +} + /// Parse a metadata key-string pair, separated by `=`. pub fn parse_string( input: ParseStream, @@ -273,9 +280,12 @@ pub mod kw { syn::custom_keyword!(title); syn::custom_keyword!(since); syn::custom_keyword!(scope); - syn::custom_keyword!(contextual); + syn::custom_keyword!(category); syn::custom_keyword!(cast); + syn::custom_keyword!(contextual); syn::custom_keyword!(constructor); + syn::custom_keyword!(custom); + syn::custom_keyword!(feature); syn::custom_keyword!(keywords); syn::custom_keyword!(parent); syn::custom_keyword!(ext); diff --git a/crates/typst-pdf/src/attach.rs b/crates/typst-pdf/src/attach.rs index d61b67414213..8e16c0cf63b7 100644 --- a/crates/typst-pdf/src/attach.rs +++ b/crates/typst-pdf/src/attach.rs @@ -5,10 +5,9 @@ use krilla::embed::{AssociationKind, EmbeddedFile, MimeType}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::{NativeElement, Smart, StyleChain}; use typst_library::introspection::Introspector; -use typst_library::pdf::{AttachElem, AttachedFileRelationship}; use crate::convert::GlobalContext; -use crate::metadata; +use crate::{AttachElem, AttachedFileRelationship, metadata}; pub(crate) fn attach_files( gc: &GlobalContext, diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index b8b9943caf86..dd4a6350296d 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -2,9 +2,7 @@ use comemo::Tracked; use ecow::{EcoString, EcoVec, eco_format}; use indexmap::IndexMap; use krilla::configure::validate::VersionedFeature; -use krilla::configure::{ - Configuration, PdfVersion, ValidationError, Validator, Validators, -}; +use krilla::configure::{PdfVersion, ValidationError, Validator, Validators}; use krilla::destination::NamedDestination; use krilla::embed::EmbedError; use krilla::error::{KrillaError, LimitError}; @@ -21,15 +19,15 @@ use typst_layout::PagedDocument; use typst_library::diag::{ At, ExpectInternal, SourceDiagnostic, SourceResult, bail, error, }; +use typst_library::format::Complete; use typst_library::foundations::{NativeElement, Repr}; use typst_library::introspection::{Introspector, Location, PagedPosition, Tag}; use typst_library::layout::{Abs, Frame, FrameItem, GroupItem, Sides, Size, Transform}; -use typst_library::model::{HeadingElem, LateLinkResolver}; +use typst_library::model::{Document as _, HeadingElem, LateLinkResolver}; use typst_library::text::FontInstance; use typst_library::visualize::{Geometry, Paint, SpotColorantName}; use typst_syntax::Span; -use crate::PdfOptions; use crate::attach::attach_files; use crate::image::handle_image; use crate::link::{LinkAnnotation, handle_link}; @@ -43,6 +41,7 @@ use crate::util::{ AbsExt, SpotColorantFromNameExt, TransformExt, ValidatorsExt, convert_path, display_font, }; +use crate::{PdfFormat, PdfOptions}; #[typst_macros::time(name = "convert document")] pub fn convert( @@ -50,21 +49,27 @@ pub fn convert( options: &PdfOptions, anchors: &[(Location, EcoString)], link_resolver: Option>, + warnings: &mut EcoVec, ) -> SourceResult> { + let options = + options.resolve(typst_document.options().get::(), warnings)?; + + let tags = tags::init(typst_document, &options)?; + let settings = SerializeSettings { - compress_content_streams: !options.pretty, + compress_content_streams: !options.format.pretty.v, no_device_cs: true, - ascii_compatible: options.pretty, + ascii_compatible: options.format.pretty.v, xmp_metadata: true, cmyk_profile: None, - configuration: options.standards.config, - enable_tagging: options.tagged, + configuration: options.format.standard.v.config, + enable_tagging: options.tagged(), render_svg_glyph_fn: render_svg_glyph, - pretty: options.pretty, + pretty: options.format.pretty.v, }; let mut document = Document::new_with(settings); - let page_index_converter = PageIndexConverter::new(typst_document, options); + let page_index_converter = PageIndexConverter::new(typst_document, &options); let named_destinations = collect_named_destinations( &mut document, typst_document, @@ -72,11 +77,9 @@ pub fn convert( &page_index_converter, ); - let tags = tags::init(typst_document, options)?; - let mut gc = GlobalContext::new( typst_document, - options, + &options, link_resolver, named_destinations, page_index_converter, @@ -91,7 +94,7 @@ pub fn convert( document.set_metadata(build_metadata(&gc, doc_lang)); document.set_tag_tree(tree); - finish(document, gc, options.standards.config) + finish(document, gc) } fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResult<()> { @@ -290,7 +293,7 @@ pub(crate) struct GlobalContext<'a> { /// The document to convert. pub(crate) document: &'a PagedDocument, /// Options for PDF export. - pub(crate) options: &'a PdfOptions, + pub(crate) options: &'a PdfOptions, /// Used to resolve cross-document links in bundle export. pub(crate) link_resolver: Option>>, /// Mapping between locations in the document and named destinations. @@ -303,7 +306,7 @@ pub(crate) struct GlobalContext<'a> { impl<'a> GlobalContext<'a> { pub(crate) fn new( document: &'a PagedDocument, - options: &'a PdfOptions, + options: &'a PdfOptions, link_resolver: Option>>, loc_to_names: FxHashMap, page_index_converter: PageIndexConverter, @@ -431,14 +434,10 @@ pub(crate) fn handle_group( /// Finish a krilla document and handle export errors. #[typst_macros::time(name = "finish export")] -fn finish( - document: Document, - gc: GlobalContext, - configuration: Configuration, -) -> SourceResult> { +fn finish(document: Document, gc: GlobalContext) -> SourceResult> { match document.finish() { - Ok(r) => Ok(r), - Err(e) => match e { + Ok(bytes) => Ok(bytes), + Err(err) => match err { KrillaError::Font(f, err) => { bail!( Span::detached(), @@ -446,17 +445,12 @@ fn finish( display_font(gc.fonts_backward.get(&f)); hint: "make sure the font is valid"; hint: "the used font might be unsupported by Typst"; - ); - } - KrillaError::Validation(ve) => { - let errors = ve - .iter() - .map(|(e, validators)| { - convert_error(&gc, *validators, e, configuration.version()) - }) - .collect::>(); - Err(errors) + ) } + KrillaError::Validation(ve) => Err(ve + .iter() + .map(|(e, validators)| convert_validation_error(&gc, e, *validators)) + .collect()), KrillaError::Image(_, loc, err) => { let span = to_span(loc); bail!(span, "failed to process image ({err})"); @@ -477,15 +471,14 @@ fn finish( "invalid page number for PDF file"; hint: "please report this as a bug"; ), - PdfError::VersionMismatch(v) => { - let pdf_ver = v.as_str(); - let config_ver = configuration.version(); - let cur_ver = config_ver.as_str(); + PdfError::VersionMismatch(embedded) => { + let embedded = embedded.as_str(); + let target = gc.options.version().as_str(); bail!(span, "the version of the PDF is too high"; - hint: "the current export target is {cur_ver}, while the PDF \ - has version {pdf_ver}"; - hint: "raise the export target to {pdf_ver} or higher"; + hint: "the current export target is {target}, while the PDF \ + has version {embedded}"; + hint: "raise the export target to {embedded} or higher"; hint: "or preprocess the PDF to convert it to a lower version"; ); } @@ -533,12 +526,12 @@ fn finish( } /// Converts a krilla error into a Typst error. -fn convert_error( +fn convert_validation_error( gc: &GlobalContext, - failing_validators: Validators, error: &ValidationError, - pdf_version: PdfVersion, + failing_validators: Validators, ) -> SourceDiagnostic { + let pdf_version = gc.options.version().as_str(); let prefix = eco_format!("{} error:", failing_validators.to_comma_list()); match error { ValidationError::TooLongString => error!( @@ -774,20 +767,17 @@ fn convert_error( let message = match feature { VersionedFeature::StructureOrderTabbing => { eco_format!( - "{prefix} links and other annotations cannot be navigated accessibly in {} files", - pdf_version.as_str() + "{prefix} links and other annotations cannot be navigated accessibly in {pdf_version} files" ) } VersionedFeature::HeaderFooterArtifactSubtypes => { eco_format!( - "{prefix} headers and footers cannot be made accessible in {} files", - pdf_version.as_str() + "{prefix} headers and footers cannot be made accessible in {pdf_version} files" ) } VersionedFeature::TableHeaderScope => { eco_format!( - "{prefix} table header cell cannot be accessibly tagged in {} files", - pdf_version.as_str() + "{prefix} table header cell cannot be accessibly tagged in {pdf_version} files" ) } }; @@ -797,7 +787,7 @@ fn convert_error( let min_version = feature.minimum_pdf_version(); let mut most_restrictive: Option<(Validator, PdfVersion)> = None; - for validator in gc.options.standards.config.validators() { + for validator in gc.options.validators() { let max = validator.max(); if most_restrictive.is_none_or(|(_, prev_max)| prev_max > max) { most_restrictive = Some((validator, max)); @@ -890,15 +880,13 @@ pub(crate) struct PageIndexConverter { } impl PageIndexConverter { - pub fn new(document: &PagedDocument, options: &PdfOptions) -> Self { + pub fn new(document: &PagedDocument, options: &PdfOptions) -> Self { let mut page_indices = FxHashMap::default(); let mut skipped_pages = 0; for i in 0..document.pages().len() { - if options - .page_ranges - .as_ref() - .is_some_and(|ranges| !ranges.includes_page_index(i)) + if let Some(ranges) = &options.format.pages.v + && !ranges.includes_page_index(i) { skipped_pages += 1; } else { diff --git a/crates/typst-pdf/src/format.rs b/crates/typst-pdf/src/format.rs new file mode 100644 index 000000000000..77a5ac6df857 --- /dev/null +++ b/crates/typst-pdf/src/format.rs @@ -0,0 +1,953 @@ +//! PDF-specific functionality. + +use std::fmt::{Debug, Formatter}; +use std::hash::Hash; +use std::num::NonZeroU32; + +use ecow::{EcoString, eco_format}; +use krilla::configure::{Accessibility, Archival, PdfVersion, Validator}; +use serde::{Deserialize, Serialize}; +use typst_library::World; +use typst_library::diag::{ + At, HintedStrResult, HintedString, SourceResult, StrResult, bail, +}; +use typst_library::engine::Engine; +use typst_library::format::{Complete, Fields, Format, FormatElement, Partial, Populate}; +use typst_library::foundations::{ + Args, Array, Bytes, Construct, Content, Derived, IntoValue, NativeElement, + NativeRuleMap, PathOrStr, ShowFn, Smart, StyleChain, Target, Value, +}; +use typst_library::layout::PageRanges; +use typst_library::model::{ + ArtifactElem, TableCell, TableCellKind, TableElem, TableHeaderScope, +}; +use typst_macros::{Cast, cast, elem, func, scope}; +use typst_syntax::Spanned; +use typst_utils::NonZeroExt; + +/// The format element for registering the PDF format. +pub const FORMAT: Format = Format::new::().with_rules(register); + +/// Registers show rules for PDF specific elements. +pub fn register(rules: &mut NativeRuleMap) { + rules.register(Target::Paged, ATTACH_RULE); +} + +const ATTACH_RULE: ShowFn = |_, _, _| Ok(Content::empty()); + +/// Typst's PDF export format. +/// +/// PDF files focus on accurately describing documents visually, but also have +/// facilities for annotating their structure. This hybrid approach makes them a +/// good fit for document exchange: They render exactly the same on every +/// device, but also support extraction of a document's content and structure +/// (at least to an extent). Unlike PNG files, PDFs are not bound to a specific +/// resolution. Hence, you can view them at any size without incurring a loss of +/// quality. +/// +/// = Exporting as PDF +/// == Command Line +/// PDF is Typst's default export format. Running the `compile` or `watch` +/// subcommand without specifying a format will create a PDF. When exporting to +/// PDF, you have the following configuration options: +/// +/// - Which @pdf.standard[PDF standards] Typst should enforce conformance with +/// by specifying `--pdf-standard` followed by one or multiple comma-separated +/// standards. Valid standards are `1.4`, `1.5`, `1.6`, `1.7`, `2.0`, `a-1b`, +/// `a-1a`, `a-2b`, `a-2u`, `a-2a`, `a-3b`, `a-3u`, `a-3a`, `a-4`, `a-4f`, +/// `a-4e`, and `ua-1`. By default, Typst outputs PDF-1.7-compliant files. +/// +/// - You can disable PDF tagging completely with `--pdf-tagged=false`. By +/// default, Typst will always write _Tagged PDF_ to provide a baseline level +/// of accessibility. Using this flag, you can turn tags off. This will make +/// your file inaccessible and prevent conformance with accessible conformance +/// levels of PDF/A and all parts of PDF/UA. +/// +/// - Which pages to export by specifying `--pages` followed by a +/// comma-separated list of numbers or dash-separated number ranges. Ranges +/// can be half-open. Example: `2,3,7-9,11-`. +/// +/// == Web App +/// Click the quick download button at the top right to export a PDF with +/// default settings. For further configuration, click "File" > "Export as" > +/// "PDF" or click the downwards-facing arrow next to the quick download button +/// and select "Export as PDF". When exporting to PDF, you have the following +/// configuration options: +/// +/// - Which PDF standards Typst should enforce conformance with. By default, +/// Typst outputs PDF-1.7-compliant files. You can choose the PDF version +/// freely between 1.4 and 2.0. Valid additional standards are `A-1b`, `A-1a`, +/// `A-2b`, `A-2u`, `A-2a`, `A-3b`. `A-3u`, `A-3a`, `A-4`, `A-4f`, `A-4e`, and +/// `UA-1`. +/// +/// - Which pages to export. Valid options are "All pages", "Current page", and +/// "Custom ranges". Custom ranges are a comma-separated list of numbers or +/// dash-separated number ranges. Ranges can be half-open. Example: +/// `2,3,7-9,11-`. +/// +/// = PDF-specific functionality +/// Typst exposes PDF-specific functionality in the global `pdf` element. See +/// below for the definitions and options it contains. +/// +/// This element contains some functions without a final API. They are designed +/// to enhance accessibility for documents with complex tables. This includes +/// @[`table-summary`], @pdf.header-cell[`header-cell`], and +/// @pdf.data-cell[`data-cell`]. All of these functions will be removed in a +/// future Typst release, either through integration into table functions or +/// through full removal. You can enable these functions by passing `--features +/// a11y-extras` or setting the `TYPST_FEATURES` environment variable to +/// `a11y-extras`. In the web app, these features are not available at this +/// time. +#[elem(scope, name = "pdf", title = "PDF", since = "0.13.0", Construct)] +pub struct PdfFormat { + /// Specifies which ranges of pages should be included in the PDF. This can + /// be: + /// + /// - `{none}` to export all pages. + /// - A single page range. + /// - An array of page ranges. + /// + /// A page range is one of: + /// + /// - A number that specifies exactly one page (page numbers start at one). + /// - A dictionary with the keys `from` (indicating the start of a page + /// range) and/or `to` (indicating the end of a page range). At least one + /// of the keys must be present, and both the start and end numbers are + /// inclusive. Thus the range `{(from: 1, to: 3)}`, is the list of pages + /// `{(1, 2, 3)}`. + /// + /// Specifying a single page or page range: + /// ```typ + /// #set pdf(pages: 3) + /// #set pdf(pages: (from: 3)) + /// #set pdf(pages: (to: 5)) + /// #set pdf(pages: (from: 3, to: 5)) + /// ``` + /// + /// Specifying multiple pages, page ranges, or a mix: + /// ```typ + /// #set pdf(pages: (5, 7, 9)) + /// #set pdf(pages: ((to: 3), 5, (from: 7, to: 8), (from: 10))) + /// ``` + #[default] + pub pages: Option, + + /// A list of PDF standards that Typst will enforce conformance with. This + /// can be a single standard or an array of standards. + /// + /// The International Standards Organization (ISO) has published the base + /// PDF standard and various standards that extend it to make PDFs more + /// suitable for specific use-cases. By default, Typst exports PDF 1.7 + /// files. Adobe Acrobat 8 and later as well as all other commonly used PDF + /// viewers are compatible with this PDF version. + /// + /// Some features of Typst may not be available depending on the PDF + /// standard you choose. You can combine compatible PDF/A and PDF/UA + /// standards. Notably, PDF/UA-1 and PDF/A-4 are mutually incompatible since + /// the former requires PDF 1.7 or earlier and the latter requires PDF 2.0. + /// + /// = PDF versions + /// Typst supports five different PDF versions: 1.4, 1.5, 1.6, 1.7 + /// (default), and 2.0. You can choose each of these versions for your + /// document export. However, based on the features you used there may be a + /// minimum version. Likewise, the standards you target can limit which + /// versions you can choose (see below for more). + /// + /// Here is a list on how each new version improves over PDF 1.4 for Typst + /// documents: + /// + /// - *PDF 1.5* (2003): Improved color management, text extraction, table + /// accessibility, reflow, and emoji fonts + /// - *PDF 1.6* (2004): More flexible links + /// - *PDF 1.7* (2006): Allows @pdf.attach[attachments], improved reflow + /// - *PDF 2.0* (2017): Improved both metadata and tag semantics for + /// accessibility + /// + /// The software used to read your file must support your PDF version. Under + /// normal circumstances, this poses no problem, but it can be a source of + /// errors when working with older hardware. For general exchange, we + /// recommend keeping the default PDF 1.7 setting or choosing PDF 2.0. + /// + /// When using PDF files as @image[images] in your document, the export PDF + /// version must equal or exceed the image file versions. + /// + /// = PDF/UA + /// Typst supports writing PDF/UA-conformant files. PDF/UA files are + /// designed for _@guides:accessibility:basics[Universal Access]._ When you + /// choose this PDF standard, Typst will run additional checks when + /// exporting your document. These checks will make sure that you are + /// following accessibility best practices. For example, it will make sure + /// that all your images come with alternative descriptions. + /// + /// Note that there are some rules in PDF/UA that are crucial for + /// accessibility but cannot be automatically checked. Hence, when exporting + /// a PDF/UA-1 document, make sure you did the following: + /// + /// - If your document is written in a different language than English, make + /// sure @text.lang[set the text language] before any content. + /// - Make sure to use Typst's semantic elements (like @heading[headings], + /// @figure[figures], and @list[lists]) when appropriate instead of + /// defining custom constructs. This lets Typst know (and export) the role + /// a construct plays in the document. See @guides:accessibility[the + /// Accessibility guide] for more details. + /// - Do not exclusively use contrast, color, format, or layout to + /// communicate an idea. Use text or alternative descriptions instead of + /// or in addition to these elements. + /// - Wrap all decorative elements without a semantic meaning in + /// @pdf.artifact. + /// - Do not use images of text. Instead, insert the text directly into your + /// markup. + /// + /// Typst currently only supports part one (PDF/UA-1) which is based on PDF + /// 1.7 (2006). When exporting to PDF/UA-1, be aware that you will need to + /// manually provide @math:accessibility[alternative descriptions of + /// mathematics] in natural language. + /// + /// New accessibility features were added to PDF 2.0 (2017). When set to PDF + /// 2.0 export, Typst will leverage some of these features. PDF 2.0 and + /// PDF/UA-1, however, are mutually incompatible. For accessible documents, + /// we currently recommend exporting to PDF/UA-1 instead of PDF 2.0 for the + /// additional checks and greater compatibility. The second part of PDF/UA + /// is designed for PDF 2.0, but not yet supported by Typst. + /// + /// = PDF/A + /// Typst optionally supports emitting PDF/A-conformant files. PDF/A files + /// are geared towards maximum compatibility with current and future PDF + /// tooling. They do not rely on difficult-to-implement or proprietary + /// features and contain exhaustive metadata. This makes them suitable for + /// long-term archival. + /// + /// The PDF/A Standard has multiple versions (_parts_ in ISO terminology) + /// and most parts have multiple profiles that indicate the file's + /// conformance level. You can target one part and conformance level at a + /// time. Currently, Typst supports these PDF/A output profiles: + /// + /// - *PDF/A-1b:* The _basic_ conformance level of ISO 19005-1. This version + /// of PDF/A is based on PDF 1.4 (2001) and results in self-contained, + /// archivable PDF files. As opposed to later parts of the PDF/A standard, + /// transparency is not allowed in PDF/A-1 files. + /// + /// - *PDF/A-1a:* This is the _accessible_ conformance level that builds on + /// the basic level PDF/A-1b. To conform to this level, your file must be + /// an accessible _Tagged PDF_ file. Note that accessibility is improved + /// with later parts as not all PDF accessibility features are available + /// for PDF 1.4 files. Furthermore, all text in the file must consist of + /// known Unicode code points. + /// + /// - *PDF/A-2b:* The _basic_ conformance level of ISO 19005-2. This version + /// of PDF/A is based on PDF 1.7 (2006) and results in self-contained, + /// archivable PDF files. + /// + /// - *PDF/A-2u:* This is the _Unicode-mappable_ conformance level that + /// builds on the basic level A-2b. It also adds rules that all text in + /// the document must consist of known Unicode code points. If possible, + /// always prefer this standard over PDF/A-2b. + /// + /// - *PDF/A-2a:* This is the _accessible_ conformance level that builds on + /// the Unicode-mappable level A-2u. This conformance level also adds two + /// requirements: Your file must be an accessible _Tagged PDF_ file. Typst + /// automatically adds tags to help you reach this conformance level. Also + /// pay attention to the Accessibility Sections throughout the reference + /// and the @guides:accessibility[Accessibility Guide] when targeting this + /// conformance level. Finally, PDF/A-2a forbids you from using code + /// points in the + /// #link("https://en.wikipedia.org/wiki/Private_Use_Areas")[Unicode + /// Private Use area]. If you want to build an accessible file, also + /// consider additionally targeting PDF/UA-1, which enables more automatic + /// accessibility checks. + /// + /// - *PDF/A-3b:* The _basic_ conformance level of ISO 19005-3. This version + /// of PDF/A is based on PDF 1.7 (2006) and results in archivable PDF + /// files that can contain arbitrary other related files as + /// @pdf.attach[attachments]. The only difference between it and PDF/A-2b + /// is the capability to attach non-PDF/A-conformant files. + /// + /// - *PDF/A-3u:* This is the _Unicode-mappable_ conformance level that + /// builds on the basic level A-3b. Just like PDF/A-2b, this requires all + /// text to consist of known Unicode code points. These rules do not apply + /// to attachments. If possible, always prefer this standard over + /// PDF/A-3b. + /// + /// - *PDF/A-3a:* This is the _accessible_ conformance level that builds on + /// the Unicode-mappable level A-3u. Just like PDF/A-2a, this requires + /// files to be accessible _Tagged PDF_ and to not use characters from the + /// Unicode Private Use area. Just like before, these rules do not apply + /// to attachments. + /// + /// - *PDF/A-4:* The basic conformance level of ISO 19005-4. This version of + /// PDF/A is based on PDF 2.0 (2017) and results in self-contained, + /// archivable PDF files. PDF/A-4 has no parts relating to accessibility. + /// Instead, the topic has been elaborated on more in the dedicated PDF/UA + /// standard. PDF/A-4 files can conform to PDF/UA-2 (currently not + /// supported in Typst). + /// + /// - *PDF/A-4f:* The _embedded files_ conformance level that builds on the + /// basic level A-4. Files conforming to this level can contain arbitrary + /// other related files as @pdf.attach[attachments], just as files + /// conforming to part 3 of ISO 19005. The only difference between it and + /// PDF/A-4 is the capability to attach non-PDF/A-conformant files. + /// + /// - *PDF/A-4e:* The _engineering_ conformance level that builds on the + /// embedded files level A-4f. Files conforming to this level can contain + /// 3D objects. Typst does not support 3D content, so this is functionally + /// equivalent to PDF/A-4f from a Typst perspective. + /// + /// If you want to target PDF/A but are unsure about which particular + /// setting to use, there are some good rules of thumb. First, you must + /// determine your *part,* that's the "version number" of the standard. Ask + /// yourself these questions: + /// + /// + *Does pre-2006 software or equipment need to be able to read my file?* + /// If so, choose part one (PDF/A-1). + /// + /// + *If not, does my file need @pdf.attach[attachments]?* If so, you must + /// choose part three (PDF/A-3) or the embedded files level of part four + /// (PDF/A-4f). + /// + /// + *Does your file need to use features introduced in PDF 2.0?* + /// Currently, use of PDF 2.0 features in Typst is limited to minor + /// improvements in accessibility, e.g. for the @title[title element]. If + /// you can do without these improvements, maximize compatibility by + /// choosing part two (when you don't need attachments) or part three + /// (when you do need attachments). If you rely on PDF 2.0 features, use + /// part four. + /// + /// Now, you only need to choose a *conformance level* (the lowercase letter + /// at the end). + /// + /// - *If you decided on part one, two, or three,* you should typically + /// choose the accessible conformance level of your part, indicated by a + /// lowercase `a` at the end. If your document is inherently inaccessible, + /// e.g. an artist's portfolio that cannot be boiled down to alternative + /// descriptions, choose conformance level `u` instead. Only if this + /// results in a compiler error, e.g. because you used code points from + /// the Unicode Private Use Area, use the basic level `b`. + /// + /// - *If you have chosen part four,* you should choose the basic + /// conformance level (PDF/A-4) except when needing to embed files. + /// + /// When choosing between exporting PDF/A and regular PDF, keep in mind that + /// PDF/A files contain additional metadata, and that some readers will + /// prevent the user from modifying a PDF/A file. + #[default] + pub standard: PdfStandards, + + /// Whether to produce a tagged PDF document. + /// + /// Tagging is enabled by default to provide a baseline of accessibility. It + /// can be turned off manually, e.g. to reduce the size of the document, and + /// will be disabled automatically when exporting a specific page range. + #[default] + pub tagged: Smart, + + /// Whether to pretty-print the produced PDF document. + /// + /// This formats the output in a more human-readable, but less + /// space-efficient way. + #[default(false)] + pub pretty: bool, +} + +impl FormatElement for PdfFormat { + type Options = PdfFormatOptions; +} + +impl Construct for PdfFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +#[scope(category = Pdf)] +impl PdfFormat { + #[elem] + type AttachElem; + + #[elem] + type ArtifactElem; + + /// A summary of the purpose and structure of a complex table. + /// + /// This will be available for Assistive Technology (AT), such as screen + /// readers, when exporting to PDF, but not for sighted readers of your file. + /// + /// This field is intended for instructions that help the user navigate the + /// table using AT. It is not an alternative description, so do not duplicate + /// the contents of the table within. Likewise, do not use this for the core + /// takeaway of the table. Instead, include that in the text around the table + /// or, even better, in a @figure.caption[figure caption]. + /// + /// If in doubt whether your table is complex enough to warrant a summary, err + /// on the side of not including one. If you are certain that your table is + /// complex enough, consider whether a sighted user might find it challenging. + /// They might benefit from the instructions you put here, so consider printing + /// them visibly in the document instead. + /// + /// The API of this feature is temporary. Hence, calling this function requires + /// enabling the `a11y-extras` feature flag at the moment. Even if this + /// functionality should be available without a feature flag in the future, the + /// summary will remain exclusive to PDF export. + /// + /// ```example + /// #figure( + /// pdf.table-summary( + /// // The summary just provides orientation and structural + /// // information for AT users. + /// summary: "The first two columns list the names of each participant. The last column contains cells spanning multiple rows for their assigned group.", + /// table( + /// columns: 3, + /// table.header[First Name][Given Name][Group], + /// [Mike], [Davis], table.cell(rowspan: 3)[Sales], + /// [Anna], [Smith], + /// [John], [Johnson], + /// [Sara], [Wilkins], table.cell(rowspan: 2)[Operations], + /// [Tom], [Brown], + /// ), + /// ), + /// // This is the key takeaway of the table, so we put it in the caption. + /// caption: [The Sales org now has a new member], + /// ) + /// ``` + #[func(since = "0.14.0")] + #[feature = A11yExtras] + pub fn table_summary( + #[named] summary: Option, + /// The table. + table: TableElem, + ) -> Content { + table.with_summary(summary).pack() + } + + /// Explicitly defines a cell as a header cell. + /// + /// Header cells help users of Assistive Technology (AT) understand and navigate + /// complex tables. When your table is correctly marked up with header cells, AT + /// can announce the relevant header information on-demand when entering a cell. + /// + /// By default, Typst will automatically mark all cells within @table.header as + /// header cells. They will apply to the columns below them. You can use that + /// function's @table.header.level[`level`] parameter to make header cells + /// labelled by other header cells. + /// + /// The `pdf.header-cell` function allows you to indicate that a cell is a + /// header cell in the following additional situations: + /// + /// - You have a *header column* in which each cell applies to its row. In that + /// case, you pass `{"row"}` as an argument to the + /// @pdf.header-cell.scope[`scope` parameter] to indicate that the header cell + /// applies to the row. + /// - You have a cell in @table.header, for example at the very start, that + /// labels both its row and column. In that case, you pass `{"both"}` as an + /// argument to the @pdf.header-cell.scope[`scope`] parameter. + /// - You have a header cell in a row not containing other header cells. In that + /// case, you can use this function to mark it as a header cell. + /// + /// The API of this feature is temporary. Hence, calling this function requires + /// enabling the `a11y-extras` feature flag at the moment. In a future Typst + /// release, this functionality may move out of the `pdf` module so that tables + /// in other export targets can contain the same information. + /// + /// ```example + /// >>> #set text(font: "IBM Plex Sans") + /// #show table.cell.where(x: 0): set text(weight: "medium") + /// #show table.cell.where(y: 0): set text(weight: "bold") + /// + /// #table( + /// columns: 3, + /// align: (start, end, end), + /// + /// table.header( + /// // Top-left cell: Labels both the nutrient rows + /// // and the serving size columns. + /// pdf.header-cell(scope: "both")[Nutrient], + /// [Per 100g], + /// [Per Serving], + /// ), + /// + /// // First column cells are row headers + /// pdf.header-cell(scope: "row")[Calories], + /// [250 kcal], [375 kcal], + /// pdf.header-cell(scope: "row")[Protein], + /// [8g], [12g], + /// pdf.header-cell(scope: "row")[Fat], + /// [12g], [18g], + /// pdf.header-cell(scope: "row")[Carbs], + /// [30g], [45g], + /// ) + /// ``` + #[func(since = "0.14.0")] + #[feature = A11yExtras] + pub fn header_cell( + /// The nesting level of this header cell. + #[named] + #[default(NonZeroU32::ONE)] + level: NonZeroU32, + /// What track of the table this header cell applies to. + #[named] + #[default] + scope: TableHeaderScope, + /// The table cell. + /// + /// This can be content or a call to @table.cell. + cell: TableCell, + ) -> Content { + cell.with_kind(Smart::Custom(TableCellKind::Header(level, scope))) + .pack() + } + + /// Explicitly defines this cell as a data cell. + /// + /// Each cell in a table is either a header cell or a data cell. By default, all + /// cells in @table.header are header cells, and all other cells data cells. + /// + /// If your header contains a cell that is not a header cell, you can use this + /// function to mark it as a data cell. + /// + /// The API of this feature is temporary. Hence, calling this function requires + /// enabling the `a11y-extras` feature flag at the moment. In a future Typst + /// release, this functionality may move out of the `pdf` module so that tables + /// in other export targets can contain the same information. + /// + /// ```example + /// #show table.cell.where(x: 0): set text(weight: "bold") + /// #show table.cell.where(x: 1): set text(style: "italic") + /// #show table.cell.where(x: 1, y: 0): set text(style: "normal") + /// + /// #table( + /// columns: 3, + /// align: (left, left, center), + /// + /// table.header[Objective][Key Result][Status], + /// + /// table.header( + /// level: 2, + /// table.cell(colspan: 2)[Improve Customer Satisfaction], + /// // Status is data for this objective, not a header + /// pdf.data-cell[✓ On Track], + /// ), + /// [], [Increase NPS to 50+], [45], + /// [], [Reduce churn to \<5%], [4.2%], + /// + /// table.header( + /// level: 2, + /// table.cell(colspan: 2)[Grow Revenue], + /// pdf.data-cell[⚠ At Risk], + /// ), + /// [], [Achieve \$2M ARR], [\$1.8M], + /// [], [Close 50 enterprise deals], [38], + /// ) + /// ``` + #[func(since = "0.14.0")] + #[feature = A11yExtras] + pub fn data_cell( + /// The table cell. + /// + /// This can be content or a call to @table.cell. + cell: TableCell, + ) -> Content { + cell.with_kind(Smart::Custom(TableCellKind::Data)).pack() + } +} + +/// Encapsulates a list of compatible PDF standards. +#[derive(Clone, Eq, PartialEq, Hash)] +pub struct PdfStandards { + pub(crate) config: krilla::configure::Configuration, +} + +cast! { + PdfStandards, + self => { + Value::Array(Array::from_iter(self.standards().map(IntoValue::into_value))) + }, + standard: PdfStandard => PdfStandards::new([standard])?, + values: Array => { + let list = values.into_iter().map(Value::cast).collect::>>()?; + Self::new(list)? + } +} + +impl PdfStandards { + /// Validates a list of PDF standards for compatibility and returns their + /// encapsulated representation. + pub fn new>( + list: impl IntoIterator, + ) -> HintedStrResult { + use krilla::configure::{ + Accessibility, Archival, ConfigurationBuilder, ConfigurationError, PdfVersion, + }; + + use crate::util::ValidatorsExt; + + let mut version: Option = None; + let mut set_version = |v: PdfVersion| -> StrResult<()> { + if let Some(prev) = version { + bail!( + "PDF cannot conform to {} and {} at the same time", + prev.as_str(), + v.as_str(), + ); + } + version = Some(v); + Ok(()) + }; + + let mut archival_validator = None; + let mut set_archival_validator = |a: Archival| -> StrResult<()> { + if archival_validator.is_some() { + bail!("choose at most one PDF/A standard"); + } + archival_validator = Some(a); + Ok(()) + }; + + let mut accessibility_validator = None; + let mut set_accessibility_validator = |ua: Accessibility| -> StrResult<()> { + if accessibility_validator.is_some() { + bail!("choose at most one PDF/UA standard"); + } + accessibility_validator = Some(ua); + Ok(()) + }; + + for standard in list { + match standard.into() { + PdfStandard::V_1_4 => set_version(PdfVersion::Pdf14)?, + PdfStandard::V_1_5 => set_version(PdfVersion::Pdf15)?, + PdfStandard::V_1_6 => set_version(PdfVersion::Pdf16)?, + PdfStandard::V_1_7 => set_version(PdfVersion::Pdf17)?, + PdfStandard::V_2_0 => set_version(PdfVersion::Pdf20)?, + PdfStandard::A_1b => set_archival_validator(Archival::A1_B)?, + PdfStandard::A_1a => set_archival_validator(Archival::A1_A)?, + PdfStandard::A_2b => set_archival_validator(Archival::A2_B)?, + PdfStandard::A_2u => set_archival_validator(Archival::A2_U)?, + PdfStandard::A_2a => set_archival_validator(Archival::A2_A)?, + PdfStandard::A_3b => set_archival_validator(Archival::A3_B)?, + PdfStandard::A_3u => set_archival_validator(Archival::A3_U)?, + PdfStandard::A_3a => set_archival_validator(Archival::A3_A)?, + PdfStandard::A_4 => set_archival_validator(Archival::A4)?, + PdfStandard::A_4f => set_archival_validator(Archival::A4F)?, + PdfStandard::A_4e => set_archival_validator(Archival::A4E)?, + PdfStandard::UA_1 => set_accessibility_validator(Accessibility::UA1)?, + } + } + + let mut builder = ConfigurationBuilder::new(); + + if let Some(version) = version { + builder = builder.with_version(version); + } + + if let Some(archival_validator) = archival_validator { + builder = builder.with_archival_validator(archival_validator); + } + + if let Some(accessibility_validator) = accessibility_validator { + builder = builder.with_accessibility_validator(accessibility_validator); + } + + let config = builder.finish().map_err(|e| { + let (message, validators) = match e { + ConfigurationError::NoOverlappingValidatorsRange(validators) => { + let list = validators.to_and_list(); + let message = eco_format!( + "{list} are mutually incompatible because \ + they do not have any overlapping PDF versions" + ); + (message, validators) + } + ConfigurationError::VersionDoesNotMatchValidatorsRange( + version, + validators, + ) => { + let list = validators.to_and_list(); + let message = + eco_format!("{} is not compatible with {list}", version.as_str()); + (message, validators) + } + }; + HintedString::new(message) + .with_hints(validators.into_iter().map(version_hint)) + })?; + + Ok(Self { config }) + } + + /// Returns an iterator over PDF standards + pub fn standards(&self) -> impl Iterator { + std::iter::once(self.config.version().into()) + .chain(self.config.validators().into_iter().map(Into::into)) + } +} + +/// A hint specifying which PDF version a validator is compatible with. +fn version_hint(validator: krilla::configure::Validator) -> EcoString { + let min = validator.min(); + let max = validator.max(); + if let Some(min) = min { + if min == max { + eco_format!("{} requires version {}", validator.as_str(), min.as_str()) + } else { + eco_format!( + "{} requires a version between {} and {}", + validator.as_str(), + min.as_str(), + max.as_str() + ) + } + } else { + eco_format!("{} requires at least {}", validator.as_str(), max.as_str()) + } +} + +impl Debug for PdfStandards { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.pad("PdfStandards(..)") + } +} + +impl Default for PdfStandards { + fn default() -> Self { + use krilla::configure::{ConfigurationBuilder, PdfVersion}; + Self { + config: ConfigurationBuilder::new() + .with_version(PdfVersion::Pdf17) + .finish() + .unwrap(), + } + } +} + +/// A PDF standard that Typst can enforce conformance with. +/// +/// Support for more standards is planned. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast, Serialize, Deserialize)] +#[allow(non_camel_case_types)] +#[non_exhaustive] +pub enum PdfStandard { + /// PDF 1.4. + #[string("1.4")] + #[serde(rename = "1.4")] + V_1_4, + /// PDF 1.5. + #[string("1.5")] + #[serde(rename = "1.5")] + V_1_5, + /// PDF 1.6. + #[string("1.6")] + #[serde(rename = "1.6")] + V_1_6, + /// PDF 1.7. + #[string("1.7")] + #[serde(rename = "1.7")] + V_1_7, + /// PDF 2.0. + #[string("2.0")] + #[serde(rename = "2.0")] + V_2_0, + /// PDF/A-1b. + #[string("a-1b")] + #[serde(rename = "a-1b")] + A_1b, + /// PDF/A-1a. + #[string("a-1a")] + #[serde(rename = "a-1a")] + A_1a, + /// PDF/A-2b. + #[string("a-2b")] + #[serde(rename = "a-2b")] + A_2b, + /// PDF/A-2u. + #[string("a-2u")] + #[serde(rename = "a-2u")] + A_2u, + /// PDF/A-2a. + #[string("a-2a")] + #[serde(rename = "a-2a")] + A_2a, + /// PDF/A-3b. + #[string("a-3b")] + #[serde(rename = "a-3b")] + A_3b, + /// PDF/A-3u. + #[string("a-3u")] + #[serde(rename = "a-3u")] + A_3u, + /// PDF/A-3a. + #[string("a-3a")] + #[serde(rename = "a-3a")] + A_3a, + /// PDF/A-4. + #[string("a-4")] + #[serde(rename = "a-4")] + A_4, + /// PDF/A-4f. + #[string("a-4f")] + #[serde(rename = "a-4f")] + A_4f, + /// PDF/A-4e. + #[string("a-4e")] + #[serde(rename = "a-4e")] + A_4e, + /// PDF/UA-1. + #[string("ua-1")] + #[serde(rename = "ua-1")] + UA_1, +} + +impl From for PdfStandard { + fn from(value: PdfVersion) -> Self { + match value { + PdfVersion::Pdf14 => PdfStandard::V_1_4, + PdfVersion::Pdf15 => PdfStandard::V_1_5, + PdfVersion::Pdf16 => PdfStandard::V_1_6, + PdfVersion::Pdf17 => PdfStandard::V_1_7, + PdfVersion::Pdf20 => PdfStandard::V_2_0, + } + } +} + +impl From for PdfStandard { + fn from(value: Validator) -> Self { + match value { + Validator::A(archival) => match archival { + Archival::A1_A => PdfStandard::A_1a, + Archival::A1_B => PdfStandard::A_1b, + Archival::A2_A => PdfStandard::A_2a, + Archival::A2_B => PdfStandard::A_2b, + Archival::A2_U => PdfStandard::A_2u, + Archival::A3_A => PdfStandard::A_3a, + Archival::A3_B => PdfStandard::A_3b, + Archival::A3_U => PdfStandard::A_3u, + Archival::A4 => PdfStandard::A_4, + Archival::A4F => PdfStandard::A_4f, + Archival::A4E => PdfStandard::A_4e, + }, + Validator::Ua(accessibility) => match accessibility { + Accessibility::UA1 => PdfStandard::UA_1, + }, + } + } +} + +/// Document settings for PDF export. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct PdfFormatOptions { + /// Specifies which ranges of pages should be included in the PDF. When + /// `None`, all pages should be exported. + pub pages: F::Value, + /// A list of PDF standards that Typst will enforce conformance with. + pub standard: F::Value, + /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF + /// document is written to provide a baseline of accessibility. In some + /// circumstances, for example when trying to reduce the size of a document, + /// it can be desirable to disable tagged PDF. + pub tagged: F::Value, + /// Wether to format the PDF in a human readable way. + pub pretty: F::Value, +} + +impl Populate for PdfFormatOptions { + fn populate(&mut self, styles: Spanned) { + // VOLATILE: This must be updated when adding more fields. + self.pages.populate(styles); + self.standard.populate(styles); + self.tagged.populate(styles); + self.pretty.populate(styles); + } +} + +impl PdfFormatOptions { + /// Resolves the [`Partial`] options to [`Complete`] ones, given defaults. + pub fn resolve(&self, default: &PdfFormatOptions) -> PdfFormatOptions { + PdfFormatOptions { + pages: Partial::resolve_cloned(&self.pages, &default.pages), + standard: Partial::resolve_cloned(&self.standard, &default.standard), + tagged: Partial::resolve(self.tagged, default.tagged), + pretty: Partial::resolve(self.pretty, default.pretty), + } + } +} + +/// A file that will be attached to the output PDF. +/// +/// This can be used to distribute additional files associated with the PDF +/// within it. PDF readers will display the files in a file listing. +/// +/// Some international standards use this mechanism to attach machine-readable +/// data (e.g., ZUGFeRD/Factur-X for invoices) that mirrors the visual content +/// of the PDF. +/// +/// = Example +/// ```typ +/// #pdf.attach( +/// "experiment.csv", +/// relationship: "supplement", +/// mime-type: "text/csv", +/// description: "Raw Oxygen readings from the Arctic experiment", +/// ) +/// ``` +/// +/// = Notes +/// - This element is ignored if exporting to a format other than PDF. +/// - File attachments are not currently supported for PDF/A-2, even if the +/// attached file conforms to PDF/A-1 or PDF/A-2. +#[elem(since = "0.14.0", keywords = ["embed"], Locatable)] +pub struct AttachElem { + /// The path of the file to be attached. + /// + /// Must always be specified, but is only read from if no data is provided + /// in the following argument. + #[required] + #[parse( + let Spanned { v: path, span } = + args.expect::>("path")?; + let resolved = path.resolve_if_some(span.id()).at(span)?; + // The derived part is the virtual-root-relative resolved path. + let derived = resolved.vpath().get_without_slash().into(); + Derived::new(path, derived) + )] + pub path: Derived, + + /// Raw file data, optionally. + /// + /// If omitted, the data is read from the specified path. + #[positional] + // Not actually required as an argument, but always present as a field. + // We can't distinguish between the two at the moment. + #[required] + #[parse( + match args.eat::()? { + Some(data) => data, + None => engine.world.file(resolved.intern()).at(span)?, + } + )] + pub data: Bytes, + + /// The relationship of the attached file to the document. + /// + /// Ignored if export doesn't target PDF/A-3. + pub relationship: Option, + + /// The MIME type of the attached file. + // TODO: Use `krilla::embed::MimeType`, or a wrapper around it here. + // The problem is that it's currently completely opaque and doesn't allow + // retrieving the inner string after construction. + pub mime_type: Option, + + /// A description for the attached file. + pub description: Option, +} + +/// The relationship of an attached file with the document. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum AttachedFileRelationship { + /// The PDF document was created from the source file. + Source, + /// The file was used to derive a visual presentation in the PDF. + Data, + /// An alternative representation of the document. + Alternative, + /// Additional resources for the document. + Supplement, +} diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index 5666154f49e2..55a3552267e5 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -2,6 +2,7 @@ mod attach; mod convert; +mod format; mod image; mod link; mod metadata; @@ -13,28 +14,36 @@ mod tags; mod text; mod util; +pub use self::format::{ + AttachElem, AttachedFileRelationship, FORMAT, PdfFormat, PdfFormatOptions, + PdfStandard, PdfStandards, +}; pub use self::metadata::{Timestamp, Timezone}; -use std::fmt::{self, Debug, Formatter}; -use std::hash::{Hash, Hasher}; +use std::fmt::Debug; +use std::hash::Hash; use comemo::Tracked; -use ecow::{EcoString, eco_format}; -use krilla::configure::Accessibility; -use serde::{Deserialize, Serialize}; +use ecow::{EcoString, EcoVec}; +use krilla::configure::{PdfVersion, Validators}; use typst_layout::PagedDocument; -use typst_library::diag::{HintedStrResult, HintedString, SourceResult, StrResult, bail}; +use typst_library::diag::{SourceDiagnostic, SourceResult, Warned, bail}; +use typst_library::format::{Complete, Fields, Partial}; use typst_library::foundations::Smart; use typst_library::introspection::Location; -use typst_library::layout::PageRanges; use typst_library::model::LateLinkResolver; /// Export a document into a PDF file. /// /// Returns the raw bytes making up the PDF file. #[typst_macros::time(name = "pdf")] -pub fn pdf(document: &PagedDocument, options: &PdfOptions) -> SourceResult> { - convert::convert(document, options, &[], None) +pub fn pdf( + document: &PagedDocument, + options: &PdfOptions, +) -> Warned>> { + let mut warnings = EcoVec::new(); + let output = convert::convert(document, options, &[], None, &mut warnings); + Warned { output, warnings } } /// Export a document into a PDF file as part of a bundle. @@ -49,13 +58,16 @@ pub fn pdf_in_bundle( options: &PdfOptions, anchors: &[(Location, EcoString)], link_resolver: Tracked, -) -> SourceResult> { - convert::convert(document, options, anchors, Some(link_resolver)) +) -> Warned>> { + let mut warnings = EcoVec::new(); + let output = + convert::convert(document, options, anchors, Some(link_resolver), &mut warnings); + Warned { output, warnings } } /// Settings for PDF export. -#[derive(Debug, Hash)] -pub struct PdfOptions { +#[derive(Debug, Default, PartialEq, Eq, Hash)] +pub struct PdfOptions { /// If not `Smart::Auto`, shall be a string that uniquely and stably /// identifies the document. It should not change between compilations of /// the same document. **If you cannot provide such a stable identifier, @@ -74,258 +86,86 @@ pub struct PdfOptions { /// If not `None`, shall be the creation timestamp of the document. It will /// only be used if `set document(date: ..)` is `auto`. pub timestamp: Option, - /// Specifies which ranges of pages should be exported in the PDF. When - /// `None`, all pages should be exported. - pub page_ranges: Option, - /// A list of PDF standards that Typst will enforce conformance with. - pub standards: PdfStandards, - /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF - /// document is written to provide a baseline of accessibility. In some - /// circumstances, for example when trying to reduce the size of a document, - /// it can be desirable to disable tagged PDF. - pub tagged: bool, - /// Whether to format the PDF in a human-readable way. - pub pretty: bool, + /// Format options that override the defaults set by the document. + pub format: PdfFormatOptions, } impl PdfOptions { - /// Returns the accessibility validator. Returns `Some` for PDF/UA-1, and in - /// the future maybe PDF/UA-2. - pub(crate) fn accessibility_validator(&self) -> Option { - self.standards.config.validators().accessibility() - } -} - -impl Default for PdfOptions { - fn default() -> Self { - Self { - ident: Smart::Auto, - creator: Smart::Auto, - timestamp: None, - page_ranges: None, - standards: PdfStandards::default(), - tagged: true, - pretty: false, - } - } -} - -/// Encapsulates a list of compatible PDF standards. -#[derive(Clone)] -pub struct PdfStandards { - pub(crate) config: krilla::configure::Configuration, -} - -impl PdfStandards { - /// Validates a list of PDF standards for compatibility and returns their - /// encapsulated representation. - pub fn new(list: &[PdfStandard]) -> HintedStrResult { - use krilla::configure::{ - Accessibility, Archival, ConfigurationBuilder, ConfigurationError, PdfVersion, - }; - - use crate::util::ValidatorsExt; - - let mut version: Option = None; - let mut set_version = |v: PdfVersion| -> StrResult<()> { - if let Some(prev) = version { - bail!( - "PDF cannot conform to {} and {} at the same time", - prev.as_str(), - v.as_str(), + /// Resolves the final options given those set by the document. + pub fn resolve( + &self, + doc: &PdfFormatOptions, + warnings: &mut EcoVec, + ) -> SourceResult> { + let format = self.format.resolve(doc); + + if format.pages.v.is_some() { + if format.tagged.v == Smart::Custom(true) { + let span = format.tagged.span.or(format.pages.span); + bail!(span, "cannot enable tagged PDF and export a page range"); + } else if format.tagged.v.is_auto() { + warnings.push( + SourceDiagnostic::warning( + format.tagged.span, + "exporting a page range disables PDF tagging", + ) + .with_hints([ + "the resulting PDF will be inaccessible".into(), + "set `pdf(tagged: false)` to silence this warning".into(), + ]), ); } - version = Some(v); - Ok(()) - }; - - let mut archival_validator = None; - let mut set_archival_validator = |a: Archival| -> StrResult<()> { - if archival_validator.is_some() { - bail!("choose at most one PDF/A standard"); - } - archival_validator = Some(a); - Ok(()) - }; - - let mut accessibility_validator = None; - let mut set_accessibility_validator = |ua: Accessibility| -> StrResult<()> { - if accessibility_validator.is_some() { - bail!("choose at most one PDF/UA standard"); - } - accessibility_validator = Some(ua); - Ok(()) - }; - - for standard in list { - match standard { - PdfStandard::V_1_4 => set_version(PdfVersion::Pdf14)?, - PdfStandard::V_1_5 => set_version(PdfVersion::Pdf15)?, - PdfStandard::V_1_6 => set_version(PdfVersion::Pdf16)?, - PdfStandard::V_1_7 => set_version(PdfVersion::Pdf17)?, - PdfStandard::V_2_0 => set_version(PdfVersion::Pdf20)?, - PdfStandard::A_1b => set_archival_validator(Archival::A1_B)?, - PdfStandard::A_1a => set_archival_validator(Archival::A1_A)?, - PdfStandard::A_2b => set_archival_validator(Archival::A2_B)?, - PdfStandard::A_2u => set_archival_validator(Archival::A2_U)?, - PdfStandard::A_2a => set_archival_validator(Archival::A2_A)?, - PdfStandard::A_3b => set_archival_validator(Archival::A3_B)?, - PdfStandard::A_3u => set_archival_validator(Archival::A3_U)?, - PdfStandard::A_3a => set_archival_validator(Archival::A3_A)?, - PdfStandard::A_4 => set_archival_validator(Archival::A4)?, - PdfStandard::A_4f => set_archival_validator(Archival::A4F)?, - PdfStandard::A_4e => set_archival_validator(Archival::A4E)?, - PdfStandard::Ua_1 => set_accessibility_validator(Accessibility::UA1)?, - } } - let mut builder = ConfigurationBuilder::new(); - - if let Some(version) = version { - builder = builder.with_version(version); - } - - if let Some(archival_validator) = archival_validator { - builder = builder.with_archival_validator(archival_validator); - } - - if let Some(accessibility_validator) = accessibility_validator { - builder = builder.with_accessibility_validator(accessibility_validator); - } - - let config = builder.finish().map_err(|e| { - let (message, validators) = match e { - ConfigurationError::NoOverlappingValidatorsRange(validators) => { - let list = validators.to_and_list(); - let message = eco_format!( - "{list} are mutually incompatible because \ - they do not have any overlapping PDF versions" + const ACCESSIBLE: [(PdfStandard, &str); 4] = [ + (PdfStandard::A_1a, "PDF/A-1a"), + (PdfStandard::A_2a, "PDF/A-2a"), + (PdfStandard::A_3a, "PDF/A-3a"), + (PdfStandard::UA_1, "PDF/UA-1"), + ]; + for (standard, name) in ACCESSIBLE { + if format.standard.v.standards().any(|s| s == standard) { + if format.tagged.v == Smart::Custom(false) { + let span = format.standard.span.or(format.tagged.span); + bail!( + span, + "cannot set `pdf(tagged: false)` when exporting a {name} document" + ); + } else if format.pages.v.is_some() { + let span = format.standard.span.or(format.pages.span); + bail!( + span, + "cannot set `pdf(tagged: false)` when exporting a {name} document"; + hint: "using `pdf(pages: ...)` implies `pdf(tagged: false)`"; ); - (message, validators) - } - ConfigurationError::VersionDoesNotMatchValidatorsRange( - version, - validators, - ) => { - let list = validators.to_and_list(); - let message = - eco_format!("{} is not compatible with {list}", version.as_str()); - (message, validators) } - }; - HintedString::new(message) - .with_hints(validators.into_iter().map(version_hint)) - })?; - - Ok(Self { config }) - } -} - -/// A hint specifying which PDF version a validator is compatible with. -fn version_hint(validator: krilla::configure::Validator) -> EcoString { - let min = validator.min(); - let max = validator.max(); - if let Some(min) = min { - if min == max { - eco_format!("{} requires version {}", validator.as_str(), min.as_str()) - } else { - eco_format!( - "{} requires a version between {} and {}", - validator.as_str(), - min.as_str(), - max.as_str() - ) + } } - } else { - eco_format!("{} requires at least {}", validator.as_str(), max.as_str()) - } -} -impl Debug for PdfStandards { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - f.pad("PdfStandards(..)") + Ok(PdfOptions { + ident: self.ident.clone(), + creator: self.creator.clone(), + timestamp: self.timestamp, + format, + }) } } -impl Default for PdfStandards { - fn default() -> Self { - use krilla::configure::{ConfigurationBuilder, PdfVersion}; - Self { - config: ConfigurationBuilder::new() - .with_version(PdfVersion::Pdf17) - .finish() - .unwrap(), - } +impl PdfOptions { + pub(crate) fn version(&self) -> PdfVersion { + self.format.standard.v.config.version() } -} -// Could be turned into a derive if krilla's `Configuration` ever implements -// `Hash`. -impl Hash for PdfStandards { - fn hash(&self, state: &mut H) { - (self.config.version() as usize).hash(state); - for validator in self.config.validators() { - validator.hash(state); - } + /// Returns whether to produce a tagged PDF document. + /// By default unless explicitly disabled or when only exporting a page + /// range, tagging is enabled. + pub(crate) fn tagged(&self) -> bool { + self.format.pages.v.is_none() && self.format.tagged.v.unwrap_or(true) } -} -/// A PDF standard that Typst can enforce conformance with. -/// -/// Support for more standards is planned. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)] -#[expect(non_camel_case_types)] -#[non_exhaustive] -pub enum PdfStandard { - /// PDF 1.4. - #[serde(rename = "1.4")] - V_1_4, - /// PDF 1.5. - #[serde(rename = "1.5")] - V_1_5, - /// PDF 1.5. - #[serde(rename = "1.6")] - V_1_6, - /// PDF 1.7. - #[serde(rename = "1.7")] - V_1_7, - /// PDF 2.0. - #[serde(rename = "2.0")] - V_2_0, - /// PDF/A-1b. - #[serde(rename = "a-1b")] - A_1b, - /// PDF/A-1a. - #[serde(rename = "a-1a")] - A_1a, - /// PDF/A-2b. - #[serde(rename = "a-2b")] - A_2b, - /// PDF/A-2u. - #[serde(rename = "a-2u")] - A_2u, - /// PDF/A-2a. - #[serde(rename = "a-2a")] - A_2a, - /// PDF/A-3b. - #[serde(rename = "a-3b")] - A_3b, - /// PDF/A-3u. - #[serde(rename = "a-3u")] - A_3u, - /// PDF/A-3a. - #[serde(rename = "a-3a")] - A_3a, - /// PDF/A-4. - #[serde(rename = "a-4")] - A_4, - /// PDF/A-4f. - #[serde(rename = "a-4f")] - A_4f, - /// PDF/A-4e. - #[serde(rename = "a-4e")] - A_4e, - /// PDF/UA-1. - #[serde(rename = "ua-1")] - Ua_1, + /// Returns the accessibility validator. Returns `Some` for PDF/UA-1, and in + /// the future maybe PDF/UA-2. + pub(crate) fn validators(&self) -> Validators { + self.format.standard.v.config.validators() + } } diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs index 468a1430c011..8127a3e7666f 100644 --- a/crates/typst-pdf/src/link.rs +++ b/crates/typst-pdf/src/link.rs @@ -78,7 +78,7 @@ pub(crate) fn handle_link( if tags::disabled(gc) { if gc.tags.in_tiling - && let Some(accessibility) = gc.options.accessibility_validator() + && let Some(accessibility) = gc.options.validators().accessibility() { let validator = accessibility.as_str(); bail!( @@ -109,7 +109,7 @@ pub(crate) fn handle_link( let alt = link.alt.as_ref().map(Into::into); if gc.tags.tree.parent_artifact().is_some() { - if let Some(accessibility) = gc.options.accessibility_validator() { + if let Some(accessibility) = gc.options.validators().accessibility() { let validator = accessibility.as_str(); bail!( link.span(), @@ -142,7 +142,7 @@ pub(crate) fn handle_link( // typst/typst and then ends on another line. // ``` // The bounding box would span the entire paragraph, which is undesirable. - let join_annotations = gc.options.accessibility_validator().is_some(); + let join_annotations = gc.options.validators().accessibility().is_some(); match fc.get_link_annotation(group_id) { Some(annotation) if join_annotations => annotation.rects.push(rect), _ => { diff --git a/crates/typst-pdf/src/metadata.rs b/crates/typst-pdf/src/metadata.rs index e17533c6486a..ea627646cebf 100644 --- a/crates/typst-pdf/src/metadata.rs +++ b/crates/typst-pdf/src/metadata.rs @@ -99,7 +99,7 @@ pub fn creation_date(gc: &GlobalContext) -> Option { } /// A timestamp with timezone information. -#[derive(Debug, Copy, Clone, Hash)] +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] pub struct Timestamp { /// The datetime of the timestamp. pub(crate) datetime: Datetime, diff --git a/crates/typst-pdf/src/outline.rs b/crates/typst-pdf/src/outline.rs index ee935b973174..aabaa700a6fb 100644 --- a/crates/typst-pdf/src/outline.rs +++ b/crates/typst-pdf/src/outline.rs @@ -22,7 +22,7 @@ pub(crate) fn build_outline(gc: &GlobalContext) -> KrillaOutline { .get(StyleChain::default()) .unwrap_or_else(|| heading.outlined.get(StyleChain::default())); - let visible = gc.options.page_ranges.as_ref().is_none_or(|ranges| { + let visible = gc.options.format.pages.v.as_ref().is_none_or(|ranges| { !ranges.includes_page( gc.document .introspector() diff --git a/crates/typst-pdf/src/tags/context/table.rs b/crates/typst-pdf/src/tags/context/table.rs index db06b9492279..7212a15454ab 100644 --- a/crates/typst-pdf/src/tags/context/table.rs +++ b/crates/typst-pdf/src/tags/context/table.rs @@ -11,8 +11,7 @@ use smallvec::SmallVec; use typst_library::foundations::Packed; use typst_library::layout::resolve::{CellGrid, Line, LinePosition}; use typst_library::layout::{Abs, Sides}; -use typst_library::model::{TableCell, TableElem}; -use typst_library::pdf::{TableCellKind, TableHeaderScope}; +use typst_library::model::{TableCell, TableCellKind, TableElem, TableHeaderScope}; use typst_library::visualize::{FixedStroke, Stroke}; use crate::tags::GroupId; diff --git a/crates/typst-pdf/src/tags/groups.rs b/crates/typst-pdf/src/tags/groups.rs index c1d68461314e..49c55e4821a7 100644 --- a/crates/typst-pdf/src/tags/groups.rs +++ b/crates/typst-pdf/src/tags/groups.rs @@ -4,6 +4,7 @@ use krilla::configure::PdfVersion; use krilla::geom as kg; use krilla::tagging::{Artifact, ArtifactType, Identifier, ListNumbering, TagKind}; use rustc_hash::FxHashMap; +use typst_library::format::Complete; use typst_library::foundations::{Content, Packed}; use typst_library::introspection::Location; use typst_library::layout::{GridCell, Inherit, Size}; @@ -504,13 +505,12 @@ impl GroupKind { pub fn to_artifact( &self, - options: &PdfOptions, + options: &PdfOptions, page_size: Option, ) -> Option { let GroupKind::Artifact(artifact) = *self else { return None }; - if options.standards.config.version() == PdfVersion::Pdf17 - && artifact == ArtifactType::Background + if options.version() == PdfVersion::Pdf17 && artifact == ArtifactType::Background { // Background artifacts were introduced in PDF 1.7. However, // they require a bounding box there, which may not be readily diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index d7de9fad2667..148a115bbb33 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -4,11 +4,11 @@ use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging::{Artifact, ArtifactType, ContentTag, SpanTag}; use typst_layout::PagedDocument; -use typst_library::diag::{SourceResult, bail}; +use typst_library::diag::SourceResult; +use typst_library::format::Complete; use typst_library::layout::{FrameParent, Point, Rect, Size}; use typst_library::text::{Locale, TextItem}; use typst_library::visualize::{Image, Shape}; -use typst_syntax::Span; use crate::PdfOptions; use crate::convert::{FrameContext, GlobalContext}; @@ -25,12 +25,11 @@ mod resolve; mod tree; mod util; -pub fn init(document: &PagedDocument, options: &PdfOptions) -> SourceResult { - let tree = if options.tagged { - if options.page_ranges.is_some() { - bail!(Span::detached(), "cannot enable tagged PDF and export a page range"); - } - +pub fn init( + document: &PagedDocument, + options: &PdfOptions, +) -> SourceResult { + let tree = if options.tagged() { tree::build(document, options)? } else { Tree::empty(document, options) @@ -118,9 +117,7 @@ pub fn tiling( tiling_size.y.to_pt() as f32, ); surface.start_tagged(ContentTag::Artifact(Artifact::new( - if gc.options.standards.config.version() == PdfVersion::Pdf17 - && bbox.is_none() - { + if gc.options.version() == PdfVersion::Pdf17 && bbox.is_none() { // PDF 1.7 cannot tolerate empty bounding boxes for background // artifacts. ArtifactType::Other @@ -142,10 +139,10 @@ pub fn tiling( } /// Whether tag generation is currently disabled. Either because it has been -/// disabled by the user using the [`PdfOptions::tagged`] flag, or we're inside -/// a tiling. +/// disabled by the user using the [`crate::PdfFormatOptions::tagged`] flag, or +/// we're inside a tiling. pub fn disabled(gc: &GlobalContext) -> bool { - !gc.options.tagged || gc.tags.in_tiling + !gc.options.tagged() || gc.tags.in_tiling } /// Add all annotations that were found in the page frame. @@ -269,7 +266,7 @@ pub fn shape<'a, 'b>( } surface.start_tagged(ContentTag::Artifact(Artifact::with_kind( - if gc.options.standards.config.version() == PdfVersion::Pdf17 + if gc.options.version() == PdfVersion::Pdf17 && artifact_type == ArtifactType::Background { ArtifactType::Other @@ -287,36 +284,8 @@ fn update_bbox( compute_bbox: impl FnOnce() -> Rect, ) { if let Some(bbox) = gc.tags.tree.parent_bbox() - && gc.options.standards.config.validators().accessibility().is_some() + && gc.options.validators().accessibility().is_some() { bbox.expand_frame(fc, compute_bbox); } } - -#[cfg(test)] -mod tests { - use std::num::NonZeroUsize; - - use ecow::EcoVec; - use typst_layout::PagedDocument; - use typst_library::layout::PageRanges; - use typst_library::model::DocumentInfo; - use typst_utils::NonZeroExt; - - use crate::tags; - - #[test] - fn tagged_and_page_range() { - let options = crate::PdfOptions { - page_ranges: Some(PageRanges::new(vec![Some(NonZeroUsize::ONE)..=None])), - ..Default::default() - }; - let document = PagedDocument::new(EcoVec::new(), DocumentInfo::default()); - let res = tags::init(&document, &options); - - assert_eq!( - res.err().unwrap().first().unwrap().message, - "cannot enable tagged PDF and export a page range" - ); - } -} diff --git a/crates/typst-pdf/src/tags/resolve/mod.rs b/crates/typst-pdf/src/tags/resolve/mod.rs index 4543bec912b2..527be87f1741 100644 --- a/crates/typst-pdf/src/tags/resolve/mod.rs +++ b/crates/typst-pdf/src/tags/resolve/mod.rs @@ -5,6 +5,7 @@ use krilla::tagging::{self as kt, Node, Tag, TagKind}; use krilla::tagging::{Identifier, TagTree}; use smallvec::SmallVec; use typst_library::diag::{At, SourceDiagnostic, SourceResult, error}; +use typst_library::format::Complete; use typst_library::text::Locale; use typst_syntax::Span; @@ -33,7 +34,7 @@ pub enum TagNode { } struct Resolver<'a> { - options: &'a PdfOptions, + options: &'a PdfOptions, ctx: &'a Ctx, groups: &'a IdVec, tags: &'a mut TagStorage, @@ -192,7 +193,7 @@ fn resolve_group_node( } } - if rs.options.accessibility_validator().is_some() { + if rs.options.validators().accessibility().is_some() { validate_children(rs, &tag, &nodes); } @@ -326,7 +327,7 @@ fn build_group_tag(rs: &mut Resolver, group: &Group) -> Option { if let TagKind::Hn(tag) = &tag { let prev_level = rs.last_heading_level.map_or(0, |l| l.get()); let next_level = tag.level(); - if let Some(accessibility) = rs.options.accessibility_validator() + if let Some(accessibility) = rs.options.validators().accessibility() && next_level.get().saturating_sub(prev_level) > 1 { let span = to_span(tag.as_any().location); @@ -467,7 +468,7 @@ fn validate_children_groups( }; if !is_valid(&child.tag) { - let validator = rs.options.standards.config.validators().to_comma_list(); + let validator = rs.options.validators().to_comma_list(); let span = to_span(child.tag.location()).or(parent_span); let parent = tag_name(parent); let child = tag_name(&child.tag); @@ -483,7 +484,7 @@ fn validate_children_groups( } if caption_spans.len() > 1 { - let validator = rs.options.standards.config.validators().to_comma_list(); + let validator = rs.options.validators().to_comma_list(); let parent = tag_name(parent); let child = tag_name(&Tag::Caption.into()); @@ -503,7 +504,7 @@ fn validate_children_groups( } if contains_leaf_nodes { - let validator = rs.options.standards.config.validators().to_comma_list(); + let validator = rs.options.validators().to_comma_list(); let parent = tag_name(parent); rs.errors.push(error!( parent_span, diff --git a/crates/typst-pdf/src/tags/tree/build.rs b/crates/typst-pdf/src/tags/tree/build.rs index 1033f1470e39..57a1ab01f905 100644 --- a/crates/typst-pdf/src/tags/tree/build.rs +++ b/crates/typst-pdf/src/tags/tree/build.rs @@ -24,6 +24,7 @@ use typst_library::diag::{ At, ExpectInternal, SourceDiagnostic, SourceResult, assert_internal, bail, error, panic_internal, }; +use typst_library::format::Complete; use typst_library::foundations::{Content, ContextElem}; use typst_library::introspection::Location; use typst_library::layout::{ @@ -32,11 +33,11 @@ use typst_library::layout::{ }; use typst_library::math::EquationElem; use typst_library::model::{ - Document, EmphElem, EnumElem, FigureCaption, FigureElem, FootnoteElem, FootnoteEntry, - HeadingElem, LinkMarker, ListElem, Outlinable, OutlineEntry, ParElem, QuoteElem, - StrongElem, TableCell, TableElem, TermsElem, TitleElem, + ArtifactElem, Document, EmphElem, EnumElem, FigureCaption, FigureElem, FootnoteElem, + FootnoteEntry, HeadingElem, LinkMarker, ListElem, Outlinable, OutlineEntry, ParElem, + PdfMarkerTag, PdfMarkerTagKind, QuoteElem, StrongElem, TableCell, TableElem, + TermsElem, TitleElem, }; -use typst_library::pdf::{ArtifactElem, PdfMarkerTag, PdfMarkerTagKind}; use typst_library::text::{ HighlightElem, OverlineElem, RawElem, RawLine, StrikeElem, SubElem, SuperElem, UnderlineElem, @@ -54,7 +55,7 @@ use crate::tags::util::{ArtifactKindExt, PropertyValCopied}; use crate::util::ValidatorsExt; pub struct TreeBuilder<'a> { - options: &'a PdfOptions, + options: &'a PdfOptions, /// Each [`FrameItem::Tag`] and each [`FrameItem::Group`] with a parent /// will append a progression to this tree. This list of progressions is @@ -76,7 +77,7 @@ pub struct TreeBuilder<'a> { } impl<'a> TreeBuilder<'a> { - pub fn new(document: &PagedDocument, options: &'a PdfOptions) -> Self { + pub fn new(document: &PagedDocument, options: &'a PdfOptions) -> Self { let doc_lang = document.info().locale.custom(); let mut groups = Groups::new(); let doc = groups.new_virtual( @@ -177,7 +178,10 @@ struct StackEntry { prog_idx: u32, } -pub fn build(document: &PagedDocument, options: &PdfOptions) -> SourceResult { +pub fn build( + document: &PagedDocument, + options: &PdfOptions, +) -> SourceResult { let mut tree = TreeBuilder::new(document, options); for page in document.pages() { visit_frame(&mut tree, &page.frame)?; @@ -202,7 +206,7 @@ pub fn build(document: &PagedDocument, options: &PdfOptions) -> SourceResult GroupId { let level = heading.level().try_into().unwrap_or(NonZeroU16::MAX); let title = heading.body.plain_text().to_string(); if title.is_empty() - && let Some(accessibility) = tree.options.accessibility_validator() + && let Some(accessibility) = tree.options.validators().accessibility() { let contains_context = heading.body.traverse(&mut |c| { if c.is::() { @@ -606,7 +610,7 @@ fn progress_tree_end(tree: &mut TreeBuilder, loc: Location) -> SourceResult SourceResult Self { + pub fn empty(document: &PagedDocument, options: &PdfOptions) -> Self { TreeBuilder::new(document, options).finish() } @@ -336,7 +337,7 @@ fn open_group( groups: &Groups, state: &mut TraversalState, fc: &FrameContext, - options: &PdfOptions, + options: &PdfOptions, surface: &mut Surface, id: GroupId, ) { @@ -360,7 +361,7 @@ fn open_group( fn open_multiple_groups<'a>( state: &mut TraversalState, fc: &FrameContext, - options: &PdfOptions, + options: &PdfOptions, surface: &mut Surface, rev_iter: impl Iterator, ) { diff --git a/crates/typst-pdf/src/tags/tree/text.rs b/crates/typst-pdf/src/tags/tree/text.rs index d8ec98381b72..25c98be3b6b7 100644 --- a/crates/typst-pdf/src/tags/tree/text.rs +++ b/crates/typst-pdf/src/tags/tree/text.rs @@ -1,5 +1,6 @@ use krilla::tagging::{LineHeight, NaiveRgbColor, TextDecorationType}; use typst_library::diag::{SourceDiagnostic, error}; +use typst_library::format::Complete; use typst_library::foundations::{Content, Packed, Smart}; use typst_library::layout::Length; use typst_library::text::{ @@ -125,7 +126,7 @@ impl TextParams { pub fn resolve_text_attrs( tree: &mut Tree, - options: &PdfOptions, + options: &PdfOptions, text: &TextItem, ) -> ResolvedTextAttrs { let params = TextParams::new(text); @@ -144,7 +145,7 @@ pub fn resolve_text_attrs( } fn compute_attrs( - options: &PdfOptions, + options: &PdfOptions, items: &[(GroupId, TextAttr)], text: &TextItem, ) -> (ResolvedTextAttrs, Option) { @@ -245,7 +246,7 @@ fn compute_script( fn compute_deco<'a>( resolved: &mut Option<(&'a Content, ResolvedTextDeco)>, err: &mut Option, - options: &PdfOptions, + options: &PdfOptions, text: &TextItem, elem: &'a Content, kind: TextDecoKind, @@ -257,7 +258,7 @@ fn compute_deco<'a>( // If PDF/UA-1 is enforced, throw an error. if err.is_none() && deco.kind != kind - && let Some(accessibility) = options.accessibility_validator() + && let Some(accessibility) = options.validators().accessibility() { let validator = accessibility.as_str(); let span = elem.span(); diff --git a/crates/typst-pdf/src/tags/util/mod.rs b/crates/typst-pdf/src/tags/util/mod.rs index d2064876cbe1..7fb673bac0ac 100644 --- a/crates/typst-pdf/src/tags/util/mod.rs +++ b/crates/typst-pdf/src/tags/util/mod.rs @@ -1,6 +1,6 @@ use krilla::tagging as kt; use krilla::tagging::{ArtifactType, NaiveRgbColor}; -use typst_library::pdf::{ArtifactKind, TableHeaderScope}; +use typst_library::model::{ArtifactKind, TableHeaderScope}; use typst_library::text::Locale; use typst_library::visualize::Paint; diff --git a/crates/typst-realize/src/lib.rs b/crates/typst-realize/src/lib.rs index fd30b3eb599a..e90008f8685c 100644 --- a/crates/typst-realize/src/lib.rs +++ b/crates/typst-realize/src/lib.rs @@ -17,9 +17,9 @@ use typst_html::HtmlElem; use typst_library::diag::{At, SourceResult, bail, warning}; use typst_library::engine::Engine; use typst_library::foundations::{ - Content, Context, ContextElem, Element, NativeElement, NativeShowRule, Packed, - Recipe, RecipeIndex, Selector, SequenceElem, ShowSet, Style, StyleChain, StyledElem, - Styles, SymbolElem, Synthesize, Target, TargetElem, Transformation, + Chainable, Content, Context, ContextElem, Element, NativeElement, NativeShowRule, + Packed, Recipe, RecipeIndex, Selector, SequenceElem, ShowSet, Style, StyleChain, + StyledElem, Styles, SymbolElem, Synthesize, Target, TargetElem, Transformation, }; use typst_library::introspection::{LocationKey, SplitLocator, Tag, TagElem, TagFlags}; use typst_library::layout::{ @@ -32,7 +32,7 @@ use typst_library::model::{ }; use typst_library::routines::{Arenas, FragmentKind, Pair, RealizationKind}; use typst_library::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem}; -use typst_syntax::Span; +use typst_syntax::{Span, Spanned}; use typst_utils::{ListSet, SliceExt, SmallBitSet}; mod spaces; @@ -602,12 +602,17 @@ fn visit_styled<'a>( // Check for document and page styles. let mut pagebreak = false; + let mut populated_document_info = false; + let mut populated_format_options = false; + let full = local.chain(&outer); for style in local.iter() { let Some(elem) = style.element() else { continue }; if elem == DocumentElem::ELEM { - let local = StyleChain::new(&local); - if let RealizationKind::Document { info } = &mut s.kind { - info.populate(local); + if let RealizationKind::Document { info, .. } = &mut s.kind { + if !populated_document_info { + info.populate(full); + populated_document_info = true; + } } else if !matches!(s.kind, RealizationKind::Bundle) { bail!( style.span(), @@ -624,8 +629,8 @@ fn visit_styled<'a>( } } else if elem == TextElem::ELEM { // Infer the document locale from the first toplevel set rule. - if let RealizationKind::Document { info } = &mut s.kind { - info.populate_locale(StyleChain::new(&local)); + if let RealizationKind::Document { info, .. } = &mut s.kind { + info.populate_locale(full); } } else if elem == PageElem::ELEM { match s.kind { @@ -650,6 +655,18 @@ fn visit_styled<'a>( "page configuration is not allowed inside of containers", ), } + } else if s.engine.library.formats.iter().any(|f| f.elem == elem) { + if let RealizationKind::Document { options, .. } = &mut s.kind { + if !populated_format_options { + options.populate(Spanned::new(full, style.span())); + populated_format_options = true; + } + } else if !matches!(s.kind, RealizationKind::Bundle) { + bail!( + style.span(), + "format set rules are not allowed inside of containers", + ); + } } } diff --git a/crates/typst-render/Cargo.toml b/crates/typst-render/Cargo.toml index 347bec025111..894880be829c 100644 --- a/crates/typst-render/Cargo.toml +++ b/crates/typst-render/Cargo.toml @@ -17,6 +17,7 @@ typst-assets = { workspace = true } typst-layout = { workspace = true } typst-library = { workspace = true } typst-macros = { workspace = true } +typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } bytemuck = { workspace = true } diff --git a/crates/typst-render/src/format.rs b/crates/typst-render/src/format.rs new file mode 100644 index 000000000000..f8d04204435a --- /dev/null +++ b/crates/typst-render/src/format.rs @@ -0,0 +1,118 @@ +use typst_library::diag::{SourceResult, bail}; +use typst_library::engine::Engine; +use typst_library::format::{Complete, Fields, Format, FormatElement, Partial, Populate}; +use typst_library::foundations::{Args, Construct, Content, StyleChain}; +use typst_macros::elem; +use typst_syntax::Spanned; +use typst_utils::Scalar; + +/// The format element for registering the PNG format. +pub const FORMAT: Format = Format::new::(); + +/// Typst's PNG export format. +/// +/// Instead of creating a PDF, Typst can also directly render pages to PNG +/// raster graphics. PNGs are losslessly compressed images that can contain one +/// page at a time. When exporting a multi-page document, Typst will emit +/// multiple PNGs. PNGs are a good choice when you want to use Typst's output in +/// an image editing software or when you can use none of Typst's other export +/// formats. +/// +/// In contrast to Typst's other export formats, PNGs are bound to a specific +/// resolution. When exporting to PNG, you can configure the resolution as +/// pixels per inch (PPI). If the medium you view the PNG on has a finer +/// resolution than the PNG you exported, you will notice a loss of quality. +/// Typst calculates the resolution of your PNGs based on each page's physical +/// dimensions and the PPI. If you need guidance for choosing a PPI value, +/// consider the following: +/// +/// - A value of 300 or 600 is typical for desktop printing. +/// - Professional prints of detailed graphics can go up to 1200 PPI. +/// - If your document is only viewed at a distance, e.g. a poster, you may +/// choose a smaller value than 300. +/// - If your document is viewed on screens, a typical PPI value for a +/// smartphone is 400-500. +/// +/// Because PNGs only contain a pixel raster, the text within cannot be +/// extracted automatically (without OCR), for example by copy/paste or a screen +/// reader. If you need the text to be accessible, export a PDF or HTML file +/// instead. +/// +/// PNGs can have transparent backgrounds. By default, Typst will output a PNG +/// with an opaque white background. You can make the background transparent +/// using `[#set page(fill: none)]`. Learn more on the @page.fill[`page` +/// function's reference page]. +/// +/// = Exporting as PNG +/// == Command Line +/// Pass `--format png` to the `compile` or `watch` subcommand or provide an +/// output file name that ends with `.png`. +/// +/// If your document has more than one page, Typst will create multiple image +/// files. The output file name must then be a template string containing at +/// least one of +/// - `[{p}]`, which will be replaced by the page number +/// - `[{0p}]`, which will be replaced by the zero-padded page number (so that +/// all numbers have the same length) +/// - `[{t}]`, which will be replaced by the total number of pages +/// +/// When exporting to PNG, you have the following configuration options: +/// +/// - Which resolution to render at by specifying `--ppi` followed by a number +/// of pixels per inch. The default is `144`. +/// +/// - Which pages to export by specifying `--pages` followed by a +/// comma-separated list of numbers or dash-separated number ranges. Ranges +/// can be half-open. Example: `2,3,7-9,11-`. +/// +/// == Web App +/// Click "File" > "Export as" > "PNG" or click the downwards-facing arrow next +/// to the quick download button and select "Export as PNG". When exporting to +/// PNG, you have the following configuration options: +/// +/// - The resolution at which the pages should be rendered, as a number of +/// pixels per inch. The default is `144`. +/// +/// - Which pages to export. Valid options are "All pages", "Current page", and +/// "Custom ranges". Custom ranges are a comma-separated list of numbers or +/// dash-separated number ranges. Ranges can be half-open. Example: +/// `2,3,7-9,11-`. +#[elem(name = "png", title = "PNG", since = "unreleased", Construct)] +pub struct PngFormat { + /// The PPI (pixels per inch) to use for PNG export. + #[default(Scalar::new(144.0))] + pub ppi: Scalar, +} + +impl FormatElement for PngFormat { + type Options = PngFormatOptions; +} + +impl Construct for PngFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +/// Document settings for PNG export. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct PngFormatOptions { + /// The number of pixels per point to render at when exporting a PNG. + pub pixel_per_pt: F::Value, +} + +impl Populate for PngFormatOptions { + fn populate(&mut self, styles: Spanned) { + // VOLATILE: This must be updated when adding more fields. + self.pixel_per_pt.populate(styles); + } +} + +impl PngFormatOptions { + /// Resolves the [`Partial`] options to [`Complete`] ones, given defaults. + pub fn resolve(&self, default: &PngFormatOptions) -> PngFormatOptions { + PngFormatOptions { + pixel_per_pt: Partial::resolve(self.pixel_per_pt, default.pixel_per_pt), + } + } +} diff --git a/crates/typst-render/src/lib.rs b/crates/typst-render/src/lib.rs index d38dff242e80..3c4c4366dcbf 100644 --- a/crates/typst-render/src/lib.rs +++ b/crates/typst-render/src/lib.rs @@ -1,28 +1,32 @@ //! Rendering of Typst documents into raster images. +mod format; mod image; mod paint; mod shape; mod text; +pub use self::format::{FORMAT, PngFormat, PngFormatOptions}; + use tiny_skia as sk; use typst_layout::{Page, PagedDocument}; +use typst_library::format::{Complete, Fields, Partial}; use typst_library::layout::{ Abs, Axes, Frame, FrameItem, FrameKind, GroupItem, Point, Sides, Size, Transform, }; +use typst_library::model::Document; use typst_library::visualize::{Color, Geometry, Paint}; -use typst_utils::Scalar; /// Export a page into a raster image. /// /// This renders the page at the given number of pixels per point and returns /// the resulting `tiny-skia` pixel buffer. #[typst_macros::time(name = "render")] -pub fn render(page: &Page, opts: &RenderOptions) -> sk::Pixmap { +pub fn render(page: &Page, opts: &RenderOptions) -> sk::Pixmap { let bleed = if opts.render_bleed { page.bleed } else { Sides::default() }; let size = page.frame.size() + bleed.sum_by_axis(); - let pixel_per_pt = opts.pixel_per_pt.get() as f32; + let pixel_per_pt = opts.format.pixel_per_pt.v.get() as f32; let pxw = (pixel_per_pt * size.x.to_f32()).round().max(1.0) as u32; let pxh = (pixel_per_pt * size.y.to_f32()).round().max(1.0) as u32; @@ -54,10 +58,11 @@ pub fn render_merged( gap: Abs, fill: Option, ) -> sk::Pixmap { + let opts = opts.resolve(document.options().get::()); let pixmaps: Vec<_> = - document.pages().iter().map(|page| render(page, opts)).collect(); + document.pages().iter().map(|page| render(page, &opts)).collect(); - let pixel_per_pt = opts.pixel_per_pt.get() as f32; + let pixel_per_pt = opts.format.pixel_per_pt.v.get() as f32; let gap = (pixel_per_pt * gap.to_f32()).round() as u32; let pxw = pixmaps.iter().map(sk::Pixmap::width).max().unwrap_or_default(); let pxh = pixmaps.iter().map(|pixmap| pixmap.height()).sum::() @@ -86,28 +91,24 @@ pub fn render_merged( } /// Settings for raster image export. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct RenderOptions { - /// Controls the scale of the rendered output in pixels per typographic - /// point. By default, a value of `1.0` is used, meaning one pixel is - /// generated per point. Increasing this value produces higher-resolution - /// images, while lower values reduce the output size and rendering cost. - /// This can be useful when adjusting the final image quality for display or - /// printing purposes. - pub pixel_per_pt: Scalar, +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct RenderOptions { /// By default, rendered pages are bounded to the page size. In some /// circumstances, such as when preparing documents for print, it may be /// desirable to include content beyond these bounds to account for bleed /// margins. This field allows expanding the rendered area to include such /// bleed. pub render_bleed: bool, + /// Format options that override the defaults set by the document. + pub format: PngFormatOptions, } -impl Default for RenderOptions { - fn default() -> Self { - Self { - pixel_per_pt: Scalar::new(2.0), - render_bleed: false, +impl RenderOptions { + /// Resolves the final options given those set by the document. + pub fn resolve(&self, doc: &PngFormatOptions) -> RenderOptions { + RenderOptions { + render_bleed: self.render_bleed, + format: self.format.resolve(doc), } } } diff --git a/crates/typst-svg/Cargo.toml b/crates/typst-svg/Cargo.toml index 785c981aa146..cacf4eee22bc 100644 --- a/crates/typst-svg/Cargo.toml +++ b/crates/typst-svg/Cargo.toml @@ -17,6 +17,7 @@ typst-assets = { workspace = true } typst-layout = { workspace = true } typst-library = { workspace = true } typst-macros = { workspace = true } +typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } base64 = { workspace = true } diff --git a/crates/typst-svg/src/format.rs b/crates/typst-svg/src/format.rs new file mode 100644 index 000000000000..b099a3e0cc1c --- /dev/null +++ b/crates/typst-svg/src/format.rs @@ -0,0 +1,105 @@ +use typst_library::diag::{SourceResult, bail}; +use typst_library::engine::Engine; +use typst_library::format::{Complete, Fields, Format, FormatElement, Partial, Populate}; +use typst_library::foundations::{Args, Construct, Content, StyleChain}; +use typst_macros::elem; +use typst_syntax::Spanned; + +/// The format element for registering the SVG format. +pub const FORMAT: Format = Format::new::(); + +/// Typst's SVG export format. +/// +/// Instead of creating a PDF, Typst can also directly render pages to scalable +/// vector graphics (SVGs), which are the preferred format for embedding vector +/// graphics in web pages. Like PDF files, SVGs display your document exactly +/// how you have laid it out in Typst. Likewise, they share the benefit of not +/// being bound to a specific resolution. Hence, you can print or view SVG files +/// on any device without incurring a loss of quality. (Note that font printing +/// quality may be better with a PDF.) In contrast to a PDF, an SVG cannot +/// contain multiple pages. When exporting a multi-page document, Typst will +/// emit multiple SVGs. +/// +/// SVGs can represent text in two ways: By embedding the text itself and +/// rendering it with the fonts available on the viewer's computer or by +/// embedding the shapes of each glyph in the font used to create the document. +/// To ensure that the SVG file looks the same across all devices it is viewed +/// on, Typst chooses the latter method. This means that the text in the SVG +/// cannot be extracted automatically, for example by copy/paste or a screen +/// reader. If you need the text to be accessible, export a PDF or HTML file +/// instead. +/// +/// SVGs can have transparent backgrounds. By default, Typst will output an SVG +/// with an opaque white background. You can make the background transparent +/// using `[#set page(fill: none)]`. Learn more on the @page.fill[`page` +/// function's reference page]. +/// +/// = Exporting as SVG +/// == Command Line +/// Pass `--format svg` to the `compile` or `watch` subcommand or provide an +/// output file name that ends with `.svg`. +/// +/// If your document has more than one page, Typst will create multiple image +/// files. The output file name must then be a template string containing at +/// least one of +/// - `[{p}]`, which will be replaced by the page number +/// - `[{0p}]`, which will be replaced by the zero-padded page number (so that +/// all numbers have the same length) +/// - `[{t}]`, which will be replaced by the total number of pages +/// +/// When exporting to SVG, you have the following configuration options: +/// +/// - Which pages to export by specifying `--pages` followed by a +/// comma-separated list of numbers or dash-separated number ranges. Ranges +/// can be half-open. Example: `2,3,7-9,11-`. +/// +/// == Web App +/// Click "File" > "Export as" > "SVG" or click the downwards-facing arrow next +/// to the quick download button and select "Export as SVG". When exporting to +/// SVG, you have the following configuration options: +/// +/// - Which pages to export. Valid options are "All pages", "Current page", and +/// "Custom ranges". Custom ranges are a comma-separated list of numbers or +/// dash-separated number ranges. Ranges can be half-open. Example: +/// `2,3,7-9,11-`. +#[elem(name = "svg", title = "SVG", since = "unreleased", Construct)] +pub struct SvgFormat { + /// Whether to pretty-print the produced SVG file. + /// + /// This formats the output in a more human-readable, but less + /// space-efficient way. + #[default(false)] + pub pretty: bool, +} + +impl FormatElement for SvgFormat { + type Options = SvgFormatOptions; +} + +impl Construct for SvgFormat { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually") + } +} + +/// Document settings for SVG export. +#[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] +pub struct SvgFormatOptions { + pub pretty: F::Value, +} + +impl SvgFormatOptions { + /// Resolves the [`Partial`] options to [`Complete`] ones, given defaults. + pub fn resolve(&self, default: &SvgFormatOptions) -> SvgFormatOptions { + SvgFormatOptions { + pretty: Partial::resolve(self.pretty, default.pretty), + } + } +} + +impl Populate for SvgFormatOptions { + fn populate(&mut self, styles: Spanned) { + // VOLATILE: This must be updated when adding more fields. + self.pretty.populate(styles); + } +} diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs index 498f4eeb5aff..0b823cbbddcb 100644 --- a/crates/typst-svg/src/lib.rs +++ b/crates/typst-svg/src/lib.rs @@ -1,5 +1,6 @@ //! Rendering of Typst documents into SVG images. +mod format; mod image; mod paint; mod path; @@ -7,19 +8,21 @@ mod shape; mod text; mod write; -use comemo::Tracked; -pub use image::{WebImage, convert_image_scaling}; -use indexmap::IndexMap; -use rustc_hash::FxBuildHasher; -use typst_library::model::{Destination, LateLinkResolver}; +pub use self::format::{FORMAT, SvgFormat, SvgFormatOptions}; +pub use self::image::{WebImage, convert_image_scaling}; use std::hash::Hash; +use comemo::Tracked; use ecow::EcoString; +use indexmap::IndexMap; +use rustc_hash::FxBuildHasher; use typst_layout::{Page, PagedDocument}; +use typst_library::format::{Complete, Fields, Partial}; use typst_library::layout::{ Abs, Frame, FrameItem, FrameKind, GroupItem, Point, Ratio, Sides, Size, Transform, }; +use typst_library::model::{Destination, Document, LateLinkResolver}; use typst_library::visualize::{Geometry, Gradient, Tiling}; use xmlwriter::XmlWriter; @@ -29,11 +32,11 @@ use crate::write::{SvgDisplay, SvgElem, SvgTransform, SvgUrl, SvgWrite}; /// Export a frame into an SVG file. #[typst_macros::time(name = "svg")] -pub fn svg(page: &Page, opts: &SvgOptions) -> String { +pub fn svg(page: &Page, opts: &SvgOptions) -> String { let (size, ts) = page_bleed(page, opts); let mut renderer = SVGRenderer::new(); - let mut xml = XmlWriter::new(xml_options(opts.pretty)); + let mut xml = XmlWriter::new(xml_options(opts.format.pretty.v)); let mut svg = svg_header(&mut xml, size); let state = State::new(size); @@ -51,14 +54,14 @@ pub fn svg(page: &Page, opts: &SvgOptions) -> String { #[typst_macros::time(name = "svg in bundle")] pub fn svg_in_bundle( page: &Page, - opts: &SvgOptions, + opts: &SvgOptions, anchors: &[(Point, EcoString)], link_resolver: Tracked, ) -> String { let (size, ts) = page_bleed(page, opts); let mut renderer = SVGRenderer::with_options(Some(link_resolver)); - let mut xml = XmlWriter::new(xml_options(opts.pretty)); + let mut xml = XmlWriter::new(xml_options(opts.format.pretty.v)); let mut svg = svg_header(&mut xml, size); let state = State::new(size); @@ -123,21 +126,22 @@ pub fn svg_in_html( /// /// The gap will be added between the individual pages. pub fn svg_merged(document: &PagedDocument, opts: &SvgOptions, gap: Abs) -> String { + let opts = opts.resolve(document.options().get::()); let num_gaps = document.pages().len().saturating_sub(1) as f64; let mut size = Size::new(Abs::zero(), num_gaps * gap); for page in document.pages() { - let (page_size, _ts) = page_bleed(page, opts); + let (page_size, _ts) = page_bleed(page, &opts); size.x.set_max(page_size.x); size.y += page_size.y; } let mut renderer = SVGRenderer::new(); - let mut xml = XmlWriter::new(xml_options(opts.pretty)); + let mut xml = XmlWriter::new(xml_options(opts.format.pretty.v)); let mut svg = svg_header(&mut xml, size); let mut y = Abs::zero(); for page in document.pages() { - let (page_size, bleed_ts) = page_bleed(page, opts); + let (page_size, bleed_ts) = page_bleed(page, &opts); let state = State::new(page_size); renderer.render_page( &mut svg, @@ -152,7 +156,7 @@ pub fn svg_merged(document: &PagedDocument, opts: &SvgOptions, gap: Abs) -> Stri xml.end_document() } -fn page_bleed(page: &Page, opts: &SvgOptions) -> (Size, Transform) { +fn page_bleed(page: &Page, opts: &SvgOptions) -> (Size, Transform) { let bleed = if opts.render_bleed { page.bleed } else { Sides::default() }; let size = page.frame.size() + bleed.sum_by_axis(); let ts = Transform::translate(bleed.left, bleed.top); @@ -173,15 +177,25 @@ fn xml_options(pretty: bool) -> xmlwriter::Options { /// Settings for SVG export. #[derive(Debug, Default, Clone, Eq, PartialEq, Hash)] -pub struct SvgOptions { +pub struct SvgOptions { /// By default, SVG documents are bounded to the page size. In some /// circumstances, such as when preparing documents for print, it may be /// desirable to include content beyond these bounds to account for bleed /// margins. This field allows expanding the document area to include such /// bleed. pub render_bleed: bool, - /// Whether to format the SVG in a human-readable way. - pub pretty: bool, + /// Format options that override the defaults set by the document. + pub format: SvgFormatOptions, +} + +impl SvgOptions { + /// Resolves the final options given those set by the document. + pub fn resolve(&self, doc: &SvgFormatOptions) -> SvgOptions { + SvgOptions { + render_bleed: self.render_bleed, + format: self.format.resolve(doc), + } + } } /// Renders one or multiple frames to an SVG file. diff --git a/crates/typst/Cargo.toml b/crates/typst/Cargo.toml index 7e5eddded008..83e35a121d4b 100644 --- a/crates/typst/Cargo.toml +++ b/crates/typst/Cargo.toml @@ -27,5 +27,8 @@ comemo = { workspace = true } ecow = { workspace = true } rustc-hash = { workspace = true } -[lints] -workspace = true +[dev-dependencies] +typst-bundle = { workspace = true } +typst-pdf = { workspace = true } +typst-render = { workspace = true } +typst-svg = { workspace = true } diff --git a/crates/typst/src/lib.rs b/crates/typst/src/lib.rs index 54947d14f85b..895194bd1f68 100644 --- a/crates/typst/src/lib.rs +++ b/crates/typst/src/lib.rs @@ -49,6 +49,7 @@ use typst_library::diag::{ FileError, SourceDiagnostic, SourceResult, Warned, bail, warning, }; use typst_library::engine::{Engine, Route, Sink, Traced}; +use typst_library::format::Format; use typst_library::foundations::{ NativeRuleMap, Output, StyleChain, Styles, Target, TargetElem, Value, }; @@ -288,19 +289,36 @@ fn warn_or_error_for_bundle(features: &Features, sink: &mut Sink) -> SourceResul /// Provides ways to construct a [`Library`]. pub trait LibraryExt { /// Creates the default library. - fn default() -> Library; + /// + /// Requires passing in all formats that should be registered, see + /// [`Self::builder()`] + #[expect(clippy::new_ret_no_self)] + fn new(formats: impl IntoIterator) -> Library; /// Creates a builder for configuring a library. - fn builder() -> LibraryBuilder; + /// + /// Requires passing in all formats that should be registered. + /// ``` + /// use typst::{Library, LibraryExt}; + /// + /// Library::builder([ + /// typst_html::FORMAT, + /// typst_pdf::FORMAT, + /// typst_svg::FORMAT, + /// typst_render::FORMAT, + /// typst_bundle::FORMAT, + /// ]); + /// ``` + fn builder(formats: impl IntoIterator) -> LibraryBuilder; } impl LibraryExt for Library { - fn default() -> Library { - Self::builder().build() + fn new(formats: impl IntoIterator) -> Library { + Self::builder(formats).build() } - fn builder() -> LibraryBuilder { - LibraryBuilder::from_routines(&ROUTINES) + fn builder(formats: impl IntoIterator) -> LibraryBuilder { + LibraryBuilder::from_routines(&ROUTINES, formats) } } @@ -319,7 +337,21 @@ static ROUTINES: LazyLock = LazyLock::new(|| Routines { eval_closure: typst_eval::eval_closure, realize: typst_realize::realize, layout_frame: typst_layout::layout_frame, - html_module: typst_html::module, html_mathml_body: typst_html::html_mathml_body, html_span_filled: typst_html::html_span_filled, }); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn formats() { + // No formats at all. + Library::new([]); + // Only the HTML format. + Library::new([typst_html::FORMAT]); + // Only a paged format. + Library::new([typst_svg::FORMAT]); + } +} diff --git a/docs/components/base.typ b/docs/components/base.typ index b87e1045ef03..a723ace7b656 100644 --- a/docs/components/base.typ +++ b/docs/components/base.typ @@ -341,3 +341,7 @@ small(body) } } + +// Emits category settings as labelled metadata to be picked up and used when +// generating a sub-category definitions section. +#let category-settings(..args) = [#metadata(args.named())] diff --git a/docs/components/category.typ b/docs/components/category.typ index a090c0eb7b27..d6cb7b1f0672 100644 --- a/docs/components/category.typ +++ b/docs/components/category.typ @@ -14,13 +14,15 @@ #import "styling.typ": prose-styling #import "table.typ": docs-table - // Build a scope from a module and binding info. -#let scope-from(mod, info) = ( - mod: mod, - dict: dictionary(mod), - info: info, -) +#let scope-from(val, info) = { + let mod = if type(val) == function { + stdx.describe(val).scope + } else { + val + } + (val: val, dict: dictionary(mod), info: info) +} // Build the scope information for a module. // @@ -33,7 +35,7 @@ // Get nested binding information, feature gates of the parent scope info will // be forwarded to the nested item info. #let nested-binding(scope, key) = { - let info = stdx.binding(scope.mod, key) + let info = stdx.binding(scope.val, key) if info.feature == none { info.feature = scope.info.feature } @@ -219,7 +221,10 @@ { show raw.where(lang: "example"): example.with(folding: true) set par(spacing: 1em) - prose-styling(live-docs(param.docs, param.def-site)) + heading-offset(2, prose-styling( + live-docs(param.docs, param.def-site), + base-target: param-label, + )) } // For things that take constant strings, we show a table with additional @@ -252,13 +257,17 @@ // Displays a signature overview followed by docs for the individual parameters. #let params-section( func, - path, params, returns, base-label, + path: auto, indent: false, muted: false, ) = { + if path == auto { + path = std-path-of(func).split(".") + } + param-signature(func, path, params, returns) // A bit of indent in paged export. @@ -388,7 +397,7 @@ params = params.slice(1) ("self", info.name) } else { - std-path-of(func).split(".") + auto } if "typed-html" in info.keywords { @@ -397,15 +406,14 @@ heading-offset(1, params-section( func, - path, params, info.returns, base-label, + path: path, indent: true, muted: muted, )) - heading-offset(2, definitions-section( info.name, scope-from(info.scope, binding-info), @@ -414,10 +422,9 @@ } // Documents the constructor of a type. -#let constructor-section(func, path: none) = { +#let constructor-section(func, path) = { let info = stdx.describe(func) let base-label = - let path = if path != none { path } else { (info.name,) } let title = short-or-long( [Constructor], @@ -442,9 +449,9 @@ params-section( func, - path, info.params, info.returns, + path: path, base-label, indent: true, ) @@ -499,7 +506,7 @@ if info.constructor != none { heading-offset(2, constructor-section( info.constructor, - path: std-path-of(ty).split("."), + std-path-of(ty).split("."), )) } @@ -561,7 +568,13 @@ } // Renders a section for a function. -#let func-section(base-route, name, func, info, binding-info) = { +#let func-section( + base-route, + name, + func, + info, + binding-info, +) = { show: func-or-ty-section.with( kind: "Function", route: base-route + "/" + name, @@ -584,7 +597,6 @@ labelled(heading[Parameters], base-label) params-section( func, - (info.name,), info.params, info.returns, base-label, @@ -617,7 +629,10 @@ ) if ty-info.constructor != none { - constructor-section(ty-info.constructor) + constructor-section( + ty-info.constructor, + std-path-of(ty).split("."), + ) } definitions-section( @@ -685,14 +700,14 @@ // For HTML let lis = () - for (name, value, info, _) in definitions { - let def-target = if value == none { + for (kind, name, value, info) in definitions { + let def-target = if kind == "group" { group-target(def-target, info) } else { value } - let body = if value == none and not "def-target" in info { + let body = if kind == "group" and not "def-target" in info { name } else { raw(name) @@ -748,6 +763,152 @@ } } +// Builds a sorted list of definitions from the scope, filtering out grouped +// definitions and sub-categories, and including provided scope additions. +#let build-category-definitions( + category: none, + scope: none, + scope-additions: (:), + groups: (), + sub-categories: (:), +) = { + assert.ne(category, none, message: "category is required") + assert.ne(scope, none, message: "scope is required") + + let definitions = { + // Direct definitions from the scope. + let skip = { + groups.map(g => g.items.values()).flatten() + sub-categories.values() + } + scope + .dict + .pairs() + .filter(((k, v)) => ( + stdx.binding(scope.val, k).category == category + and type(v) in (function, type) + and v not in skip + and not ( + (scope.val == math and k == "text") // dupe + or (scope.val == pdf and k == "embed") // deprecated + or (scope.val == std and k == "pattern") // deprecated + ) + )) + .map(((k, v)) => ("direct", k, v, stdx.describe(v))) + + // Manual ddditions. + scope-additions.pairs().map(((k, v)) => ("addition", k, v, stdx.describe(v))) + + // Grouped definitions. + groups.map(g => ("group", g.name, none, g)) + + // Sub-categories. + sub-categories.pairs().map(((k, v)) => ("category", k, v, stdx.describe(v))) + } + + definitions.sorted(key: ((.., info)) => info.title) +} + +// Show a list of category definitions built by `build-category-definitions()`. +#let category-definitions( + route, + scope, + def-target, + definitions, + func-category: none, +) = { + // The individual sections for all definitions. + show: paged-heading-offset.with(1) + + for (kind, name, value, info) in definitions { + if kind in ("direct", "addition", "category") { + let binding-info = if kind != "addition" { nested-binding(scope, name) } + + if kind == "category" { + func-category(route, name, value, info, binding-info) + } else if type(value) == function { + func-section(route, name, value, info, binding-info) + } else if type(value) == type { + ty-section(route, name, value, info, binding-info) + } + } else if kind == "group" { + let group = info + if group.at("scope", default: none) == none { + group.scope = scope + } + group-section(route, def-target, group) + } + } +} + +// Renders a section for a combined type and category (used for export formats). +#let func-category( + base-route, + // The name of the function. + name, + // The value of the function. + func, + // The dict returned by `stdx.describe()`. + info, + // The function binding information returned by `stdx.binding()`. + binding-info, +) = context { + let def-target = func + let route = base-route + "/" + name + let scope = scope-from(func, binding-info) + + let settings = query(selector().within(here())) + .map(m => m.value) + .first(default: none) + + let definitions = build-category-definitions( + category: name, + scope: scope, + ..settings, + ) + + func-or-ty-section( + kind: "Function", + route: route, + title: info.title, + title-fmt: raw(info.name), + subtitle: func-subtitle(info, binding-info), + has-summary: true, + keywords: info.keywords, + def-target: def-target, + description: "Documentation for the `" + name + "` function.", + { + prose-styling( + live-docs(info.docs, info.def-site), + base-target: func, + ) + + { + let base-label = + labelled(heading[Parameters], base-label) + params-section( + func, + info.params, + info.returns, + base-label, + ) + } + + if definitions.len() > 0 { + category-outline(definitions, def-target) + } + }, + ) + + category-definitions( + route, + scope, + def-target, + definitions, + func-category: func-category, + ) +} + // Renders the docs for one category, including an overview section and sections // for all definitions in the category. #let docs-category( @@ -764,6 +925,8 @@ scope-additions: (:), // Definitions that should be documented together as a group. groups: (), + // Definitions that should be documented as a sub-category. + sub-categories: (:), // General documentation prose for the category. body, ) = { @@ -773,7 +936,7 @@ let def-target = if scope == none { label("reference:" + category) } else { - scope.mod + scope.val } let scope = if scope != none { @@ -782,37 +945,13 @@ scope-from(std, (feature: none)) } - let definitions = { - // Non-grouped definitions from the scope. - let skip = groups.map(g => g.items.map(((k, v)) => v)).flatten() - scope - .dict - .pairs() - .filter(((k, v)) => ( - stdx.binding(scope.mod, k).category == category - and type(v) in (function, type) - and v not in skip - and not ( - (scope.mod == math and k == "text") // dupe - or (scope.mod == pdf and k == "embed") // deprecated - or (scope.mod == std and k == "pattern") // deprecated - ) - )) - .map(((k, v)) => ( - k, - v, - stdx.describe(v), - nested-binding(scope, k), - )) - - // Manual ddditions. - scope-additions.pairs().map(((k, v)) => (k, v, stdx.describe(v), none)) - - // Grouped definitions. - groups.map(g => (g.name, none, g, none)) - } - - let definitions = definitions.sorted(key: ((.., info, _)) => info.title) + let definitions = build-category-definitions( + category: category, + scope: scope, + scope-additions: scope-additions, + groups: groups, + sub-categories: sub-categories, + ) // The category overview section with an outline of definitions. docs-section( @@ -830,18 +969,11 @@ }, ) - // The individual sections for all definitions. - show: paged-heading-offset.with(1) - for (name, value, info, binding-info) in definitions { - if type(value) == function { - func-section(route, name, value, info, binding-info) - } else if type(value) == type { - ty-section(route, name, value, info, binding-info) - } else { - if info.at("scope", default: none) == none { - info.scope = scope - } - group-section(route, def-target, info) - } - } + category-definitions( + route, + scope, + def-target, + definitions, + func-category: func-category, + ) } diff --git a/docs/components/live.typ b/docs/components/live.typ index 124193545460..34d06a7dd3bc 100644 --- a/docs/components/live.typ +++ b/docs/components/live.typ @@ -2,17 +2,22 @@ // // Cooperates with `docs/src/live.rs`. -#import "base.typ": folding-details, short-or-long +#import "base.typ": ( + category-settings, folding-details, info, insertion, short-or-long, +) #import "example.typ": example #import "figure.typ": docs-figure #import "table.typ": docs-table // The definitions that are available in Rust doc comments. #let scope = ( - example: example, - folding-details: folding-details, + category-settings: category-settings, docs-figure: docs-figure, docs-table: docs-table, + example: example, + folding-details: folding-details, + info: info, + insertion: insertion, short-or-long: short-or-long, ) diff --git a/docs/content/changelog/0.14.0.typ b/docs/content/changelog/0.14.0.typ index 029df29ce3bc..3414b9f9b06e 100644 --- a/docs/content/changelog/0.14.0.typ +++ b/docs/content/changelog/0.14.0.typ @@ -2,7 +2,7 @@ = Highlights - Typst now produces @guides:accessibility[_accessible_ PDFs] out of the box, with opt-in support for stricter checks and conformance to PDF/UA-1 -- Typst now supports all @pdf:pdf-a[PDF/A standards] +- Typst now supports all @pdf.standard:pdf-a[PDF/A standards] - PDFs can now be used as @image.format[images] (thanks to #gh("LaurenzV")) - Added support for @par.justification-limits[character-level justification] (can significantly improve the appearance of justified text) - Added support for many more built-in elements in HTML export @@ -19,8 +19,8 @@ PDF export was fully rewritten to use the new #link("https://github.com/LaurenzV - In addition, Typst can now emit documents conforming to the PDF/UA-1 standard. (PDF/UA-2 is not yet supported, but planned.) - There is an increasing amount of existing and upcoming legislation requiring documents to be accessible, for instance, the European Accessibility Act and the Americans with Disabilities Act. - For more details on all of this, read the new @guides:accessibility[Accessibility Guide]. -- Typst now supports all PDF/A standards: PDF/A-1b, PDF/A-1a, PDF/A-2b, PDF/A-2u, PDF/A-2a, PDF/A-3b, PDF/A-3u, PDF/A-3a, PDF/A-4, PDF/A-4f, and PDF/A-4e. See the @pdf:pdf-a[expanded PDF/A documentation] for guidance on how to select a suitable standard. #pr(5420) #pr(7038) -- Typst now supports the PDF versions 1.4, 1.5, 1.6, and 2.0 in addition to PDF 1.7. See the @pdf:pdf-versions[relevant section of the PDF documentation] for details. #pr(5420) +- Typst now supports all PDF/A standards: PDF/A-1b, PDF/A-1a, PDF/A-2b, PDF/A-2u, PDF/A-2a, PDF/A-3b, PDF/A-3u, PDF/A-3a, PDF/A-4, PDF/A-4f, and PDF/A-4e. See the @pdf.standard:pdf-a[expanded PDF/A documentation] for guidance on how to select a suitable standard. #pr(5420) #pr(7038) +- Typst now supports the PDF versions 1.4, 1.5, 1.6, and 2.0 in addition to PDF 1.7. See the @pdf.standard:pdf-versions[relevant section of the PDF documentation] for details. #pr(5420) - Added @pdf.artifact function for marking content as not semantically meaningful #pr(6619) - Added experimental @pdf.header-cell, @pdf.data-cell, and @pdf.table-summary functions for enhancing accessibility of documents with complex tables. These functions are guarded by the `a11y-extras` feature. They do not have a final interface and will be removed in the future, either through integration into table functions or through full removal. #pr(6619) - PDF heading bookmarks now contain the heading's numbering #pr(6622) diff --git a/docs/content/changelog/0.15.0.typ b/docs/content/changelog/0.15.0.typ index 5ee8abc11209..a1d71d34dfa7 100644 --- a/docs/content/changelog/0.15.0.typ +++ b/docs/content/changelog/0.15.0.typ @@ -12,9 +12,9 @@ This section documents all changes to the Typst language and compiler between Ty = Highlights - Typst now supports @text.variations[variable fonts] - HTML export now supports equations out of the box via MathML -- With the new, experimental @reference:bundle[_bundle_] export target, a single Typst project can output multiple files (e.g. a multi-page website) +- With the new, experimental @format.bundle[_bundle_] export target, a single Typst project can output multiple files (e.g. a multi-page website) - A single document can now contain multiple @bibliography.target[bibliographies] -- Typst can now target multiple @pdf:pdf-standards[PDF standards] at once +- Typst can now target multiple @pdf.standard[PDF standards] at once - The new @selector.within[`within`] selector simplifies many introspection use cases - The new @divider element represents a thematic break that templates can style - @color.spot[Spot colors] enable use of custom pigments in offset printing @@ -196,9 +196,9 @@ This section documents all changes to the Typst language and compiler between Ty = Export == Bundle -- Added new, experimental @reference:bundle[_bundle_ export target] #pr(7964) +- Added new, experimental @format.bundle[_bundle_ export target] #pr(7964) - With bundle export, you can emit multiple output files from a single Typst project - - Bundles can contain any combination of @html[HTML pages], @pdf[PDFs], @reference:png[PNGs], @reference:svg[SVGs], and arbitrary @asset[assets] + - Bundles can contain any combination of @html[HTML pages], @pdf[PDFs], @format.png[PNGs], @format.svg[SVGs], and arbitrary @asset[assets] == HTML - Mathematical equations are now automatically exported to MathML _(thanks to #gh("mkorje"))_ #pr(7436) @@ -384,7 +384,7 @@ $ R != cal(R) $ ``` == #short-or-long[HTML paragraphs][Paragraphs, boxes, and blocks in HTML] -Typst automatically @par:what-becomes-a-paragraph[collects inline-level content into paragraphs]. In Typst 0.14 and below, the exact same rules were used to collect inline-level HTML elements (e.g. a ``) into Typst paragraphs, which in turn result in `

` elements. This had the unfortunate effect that even use of the low-level @html:typed[typed HTML API] could result in `

` elements appearing automatically. Consider the example below: +Typst automatically @par:what-becomes-a-paragraph[collects inline-level content into paragraphs]. In Typst 0.14 and below, the exact same rules were used to collect inline-level HTML elements (e.g. a ``) into Typst paragraphs, which in turn result in `

` elements. This had the unfortunate effect that even use of the low-level @html[typed HTML API] could result in `

` elements appearing automatically. Consider the example below: ```typ #html.div({ diff --git a/docs/content/guides/accessibility.typ b/docs/content/guides/accessibility.typ index b997eae0fa64..d80e2b4a9062 100644 --- a/docs/content/guides/accessibility.typ +++ b/docs/content/guides/accessibility.typ @@ -298,7 +298,7 @@ Typst can help you to assert that your document is accessible by checking it aga - *PDF/A-2a* and *PDF/A-3a:* Like the first part of PDF/A, these standards focus on creating files suitable for archival and long-term storage. Both of these standards target the newer PDF version 1.7 instead of PDF 1.4. Here too, the strictest conformance level A contains rules for accessibility. In addition to the rules in PDF/A-1a, these standards disallow the use of characters in the #link("https://en.wikipedia.org/wiki/Private_Use_Areas")[Unicode Private Use Area] whose meaning is not universally defined. Improvements over PDF/A-1 include the ability to use transparency and better reflow. When choosing between these two parts of the PDF/A standard, choose PDF/A-2a unless you need to @pdf.attach[attach] other files. Note that conformance level A has been removed from PDF/A-4 in favor of the dedicated PDF/UA standard. -The @pdf:pdf-standards[PDF reference page] contains more information about each supported standard. To enable either PDF/UA, PDF/A-2a, or PDF/A-3a, use the @pdf:command-line[appropriate flag in the CLI] or use the export dropdown and click on PDF in the web app. You can combine compatible PDF/A and PDF/UA standards. For accessibility-focused documents, we recommend combining PDF/UA-1 with PDF/A-2a or PDF/A-3a. +The @pdf.standard[PDF reference page] contains more information about each supported standard. To enable either PDF/UA, PDF/A-2a, or PDF/A-3a, use the @pdf:command-line[appropriate flag in the CLI] or use the export dropdown and click on PDF in the web app. You can combine compatible PDF/A and PDF/UA standards. For accessibility-focused documents, we recommend combining PDF/UA-1 with PDF/A-2a or PDF/A-3a. When you select one of these standards for PDF export, Typst will detect if you are in violation of their rules and fail the export with a descriptive error message. For the strictest accessibility check currently available, choose PDF/UA-1. Do not disable tagging unless you have a good reason, as tags provide a baseline of accessibility across all documents you export. diff --git a/docs/content/reference/export/bundle.typ b/docs/content/reference/export/bundle.typ deleted file mode 100644 index ff4f411c0b19..000000000000 --- a/docs/content/reference/export/bundle.typ +++ /dev/null @@ -1,66 +0,0 @@ -#import "../../../components/index.typ": docs-category, info - -#show: docs-category.with( - title: "Bundle", - description: "Documentation for Typst's bundle export target.", - category: "bundle", -) - -#info[ - Bundle export is only available for experimentation behind a feature flag. Do not use this feature for production use cases. In the CLI, you can experiment with it by passing `--features bundle` or setting the `TYPST_FEATURES` environment variable to `bundle`. To use both the `bundle` and the `html` feature at the same time, specify both separated with a comma (i.e. `bundle,html`). In the web app, bundle export is not available at this time. -] - -With Typst's bundle export, you can emit multiple output files from a single Typst project. Bundle output is useful for creating multi-page websites with HTML export, but it is not limited to HTML export. You can create bundles containing any combination of @html[HTML pages], @pdf[PDFs], @reference:png[PNGs], @reference:svg[SVGs], and arbitrary @asset[assets]. - -= Exporting as a bundle -== Command Line -Pass `--format bundle` to the `compile` or `watch` subcommand. Note that you must also pass `--features bundle` or set `TYPST_FEATURES=bundle` to enable this experimental export target. - -When using `typst watch`, Typst will launch a live-reloading HTTP server serving your files. You can configure it as follows: - -- Pass `--port` to change the port. (Defaults to the first free port in the range 3000-3005.) -- Pass `--no-reload` to disable injection of a live reload script into HTML pages. (The HTML that is written to disk isn't affected either way.) Non-HTML documents do not support live reload. -- Pass `--no-serve` to disable the server altogether. - -== Web App -Not currently available. - -= Creating files -A bundle is a collection of files. Each of these bundle files falls into one of two categories: Document or asset. A @document takes @content[content] that is exported with one of Typst's other export formats. Meanwhile, an @asset takes raw @bytes[byte data] of your choice that will be written to disk as-is. Both elements take the desired output path as their first argument. - -The example below shows a basic example of how bundle export could be used in practice: - -```typ -#document("index.html", title: [Home])[ - #title() - - #link()[Go to blog] -] - -#document("blog.html", title: [Blog])[ - #title() - Welcome to my blog! - - ... - - This blog also exists as a - #link()[single PDF]. -] - -#document("blog.pdf", title: [Blog])[ - ... -] - -#asset( - "favicon.ico", - read("images/favicon.ico", encoding: none), -) -``` - -In the example, we create two HTML documents: A home page and a blog. The home page links to the blog through a label link. Typst's built-in linking mechanism natively supports @link:links-in-bundle-export[cross-document links] and resolves the correct relative paths for you. The bundle also contains a PDF version of the blog, which is linked from the HTML version. In practice, you could now share the content between the HTML and PDF version by storing it in a variable and using it in both. This is omitted here for brevity. Finally, the bundle contains an icon asset for the website. In this case, we're providing the asset's data by reading a file from disk. Alternatively, it's also possible to generate asset data from within Typst (e.g. via a function like @json.encode). - -Documents and assets are normal elements, so you can use them with Typst's usual scripting, styling, and introspection mechanisms. For more details, refer to the @document and @asset documentation. - -= Introspection -Introspections always observe the full bundle rather than individual documents. For instance, querying for headings will give you all headings in all documents rather than the ones in the current document. Similarly, labels are global to the bundle—you can locate and @link:links-in-bundle-export[link to labels in other documents]. Counters and states are likewise global. #footnote[An exception to this forms the page counter, which is (naturally) per document.] In particular, if you enable things like heading numbering, the numbering will progress consecutively across the full bundle. - -If you're using bundle export to build one conceptual work that is split up across multiple output files, this is typically what you want. If, however, you're using bundle export to export multiple conceptually separate works, you might want introspections to consider each document in isolation. Currently, you'll have to do this manually (e.g. by resetting counters, adjusting selectors, etc.) We #link("https://github.com/typst/typst/issues/7735#issuecomment-3908841853")[plan to provide more tools] for managing the precise scope of introspection in the future. diff --git a/docs/content/reference/export/html.typ b/docs/content/reference/export/html.typ deleted file mode 100644 index 76749e680091..000000000000 --- a/docs/content/reference/export/html.typ +++ /dev/null @@ -1,77 +0,0 @@ -#import "../../../components/index.typ": docs-category, info, insertion, scope - -#show: docs-category.with( - title: "HTML", - description: "Documentation for Typst's HTML export target.", - category: "html", - scope: scope(std, "html"), - groups: ( - ( - name: "typed", - title: "Typed HTML", - items: { - dictionary(html) - .pairs() - .filter(((_, val)) => { - let info = stdx.describe(val) - info != none and "typed-html" in info.keywords - }) - }, - description: "Documentation for the typed HTML API.", - docs: [ - A typed layer over raw HTML elements. - - The `html` module provides a typed layer over the raw @html.elem function that allows you to conveniently create HTML elements. HTML attributes are exposed as function parameters that accept Typst types and automatically take care of converting those into the appropriate HTML. - - Some parameters are common to all typed HTML functions. These are listed at the bottom in the @html:typed:global-attributes[Global Attributes] section instead of explicitly on each element for readability. - - = Example - ```typ - #html.video( - controls: true, - width: 1280, - height: 720, - src: "sunrise.mp4", - )[ - Your browser does not support the video tag. - ] - ``` - ], - epilogue: [ - = Global Attributes - These parameters are common to all typed HTML functions. They are listed here once instead of explicitly on each element for readability. - ], - ), - ), -) - -#info[ - Typst's HTML export is currently under active development. The feature is still very incomplete and only available for experimentation behind a feature flag. Do not use this feature for production use cases. In the CLI, you can experiment with HTML export by passing `--features html` or setting the `TYPST_FEATURES` environment variable to `html`. In the web app, you can likewise enable it as an experimental feature. Visit the #link("https://github.com/typst/typst/issues/5512")[tracking issue] to follow progress on HTML export and learn more about planned features. -] - -HTML files describe a document structurally. The aim of Typst's HTML export is to capture the structure of an input document and produce semantically rich HTML that retains this structure. The resulting HTML should be accessible, human-readable, and editable by hand and downstream tools. - -PDF, PNG, and SVG export, in contrast, all produce _visual_ representations of a fully-laid out document. This divergence in the formats' intents means that Typst cannot simply produce perfect HTML for your existing Typst documents. It cannot always know what the best semantic HTML representation of your content is. - -Instead, it gives _you_ full control: You can check the current export format through the @target function and when it is set to HTML, generate @html.elem[raw HTML elements]. The primary intended use of these elements is in templates and show rules. This way, the document's contents can be fully agnostic to the export target and content can be shared between PDF and HTML export. - -In the `html` export format, Typst will output a single HTML file. You can also use Typst to create a website comprising multiple HTML documents and additional assets. Typst calls such a collection a _bundle_ and supports this use case with the dedicated @reference:bundle[`bundle` target]. Refer to its documentation to learn more. Note that both in the `html` and the `bundle` target, Typst will currently always emit a standalone HTML file. Support for emitting fragments that can be integrated into other HTML documents is planned for the future. - -Typst currently does not output CSS style sheets, instead focussing on emitting semantic markup. You can of course write your own CSS styles and still benefit from sharing your _content_ between PDF and HTML. For the future, we plan to give you the option of automatically emitting CSS, taking more of your existing set rules into account. - -= Exporting as HTML -== Command Line -Pass `--format html` to the `compile` or `watch` subcommand or provide an output file name that ends with `.html`. Note that you must also pass `--features html` or set `TYPST_FEATURES=html` to enable this experimental export target. - -When using `typst watch`, Typst will launch a live-reloading HTTP server. You can configure it as follows: - -- Pass `--port` to change the port. (Defaults to the first free port in the range 3000-3005.) -- Pass `--no-reload` to disable injection of a live reload script. (The HTML that is written to disk isn't affected either way.) -- Pass `--no-serve` to disable the server altogether. - -== Web App -HTML export can be enabled as an experimental feature in the web app. -#insertion("html-web-app") - -= HTML-specific functionality -Typst exposes HTML-specific functionality in the global `html` module. See below for the definitions it contains. diff --git a/docs/content/reference/export/index.typ b/docs/content/reference/export/index.typ deleted file mode 100644 index 0d79a1955c17..000000000000 --- a/docs/content/reference/export/index.typ +++ /dev/null @@ -1,9 +0,0 @@ -#import "../../../components/index.typ": nav-separation - -#nav-separation[Export] - -#include "pdf.typ" -#include "html.typ" -#include "png.typ" -#include "svg.typ" -#include "bundle.typ" diff --git a/docs/content/reference/export/pdf.typ b/docs/content/reference/export/pdf.typ deleted file mode 100644 index 4f3e352ebc58..000000000000 --- a/docs/content/reference/export/pdf.typ +++ /dev/null @@ -1,109 +0,0 @@ -#import "../../../components/index.typ": docs-category, scope - -#show: docs-category.with( - title: "PDF", - description: "Documentation for Typst's PDF export target.", - category: "pdf", - scope: scope(std, "pdf"), -) - -PDF files focus on accurately describing documents visually, but also have facilities for annotating their structure. This hybrid approach makes them a good fit for document exchange: They render exactly the same on every device, but also support extraction of a document's content and structure (at least to an extent). Unlike PNG files, PDFs are not bound to a specific resolution. Hence, you can view them at any size without incurring a loss of quality. - -= Exporting as PDF -== Command Line -PDF is Typst's default export format. Running the `compile` or `watch` subcommand without specifying a format will create a PDF. When exporting to PDF, you have the following configuration options: - -- Which @pdf:pdf-standards[PDF standards] Typst should enforce conformance with by specifying `--pdf-standard` followed by one or multiple comma-separated standards. Valid standards are `1.4`, `1.5`, `1.6`, `1.7`, `2.0`, `a-1b`, `a-1a`, `a-2b`, `a-2u`, `a-2a`, `a-3b`, `a-3u`, `a-3a`, `a-4`, `a-4f`, `a-4e`, and `ua-1`. By default, Typst outputs PDF-1.7-compliant files. - -- You can disable PDF tagging completely with `--no-pdf-tags`. By default, Typst will always write _Tagged PDF_ to provide a baseline level of accessibility. Using this flag, you can turn tags off. This will make your file inaccessible and prevent conformance with accessible conformance levels of PDF/A and all parts of PDF/UA. - -- Which pages to export by specifying `--pages` followed by a comma-separated list of numbers or dash-separated number ranges. Ranges can be half-open. Example: `2,3,7-9,11-`. - -== Web App -Click the quick download button at the top right to export a PDF with default settings. For further configuration, click "File" > "Export as" > "PDF" or click the downwards-facing arrow next to the quick download button and select "Export as PDF". When exporting to PDF, you have the following configuration options: - -- Which PDF standards Typst should enforce conformance with. By default, Typst outputs PDF-1.7-compliant files. You can choose the PDF version freely between 1.4 and 2.0. Valid additional standards are `A-1b`, `A-1a`, `A-2b`, `A-2u`, `A-2a`, `A-3b`. `A-3u`, `A-3a`, `A-4`, `A-4f`, `A-4e`, and `UA-1`. - -- Which pages to export. Valid options are "All pages", "Current page", and "Custom ranges". Custom ranges are a comma-separated list of numbers or dash-separated number ranges. Ranges can be half-open. Example: `2,3,7-9,11-`. - -= PDF standards -The International Standards Organization (ISO) has published the base PDF standard and various standards that extend it to make PDFs more suitable for specific use-cases. By default, Typst exports PDF 1.7 files. Adobe Acrobat 8 and later as well as all other commonly used PDF viewers are compatible with this PDF version. - -Some features of Typst may not be available depending on the PDF standard you choose. You can combine compatible PDF/A and PDF/UA standards. Notably, PDF/UA-1 and PDF/A-4 are mutually incompatible since the former requires PDF 1.7 or earlier and the latter requires PDF 2.0. - -== PDF versions -Typst supports five different PDF versions: 1.4, 1.5, 1.6, 1.7 (default), and 2.0. You can choose each of these versions for your document export. However, based on the features you used there may be a minimum version. Likewise, the standards you target can limit which versions you can choose (see below for more). - -Here is a list on how each new version improves over PDF 1.4 for Typst documents: - -- *PDF 1.5* (2003): Improved color management, text extraction, table accessibility, reflow, and emoji fonts -- *PDF 1.6* (2004): More flexible links -- *PDF 1.7* (2006): Allows @pdf.attach[attachments], improved reflow -- *PDF 2.0* (2017): Improved both metadata and tag semantics for accessibility - -The software used to read your file must support your PDF version. Under normal circumstances, this poses no problem, but it can be a source of errors when working with older hardware. For general exchange, we recommend keeping the default PDF 1.7 setting or choosing PDF 2.0. - -When using PDF files as @image[images] in your document, the export PDF version must equal or exceed the image file versions. - -== PDF/UA -Typst supports writing PDF/UA-conformant files. PDF/UA files are designed for _@guides:accessibility:basics[Universal Access]._ When you choose this PDF standard, Typst will run additional checks when exporting your document. These checks will make sure that you are following accessibility best practices. For example, it will make sure that all your images come with alternative descriptions. - -Note that there are some rules in PDF/UA that are crucial for accessibility but cannot be automatically checked. Hence, when exporting a PDF/UA-1 document, make sure you did the following: - -- If your document is written in a different language than English, make sure @text.lang[set the text language] before any content. -- Make sure to use Typst's semantic elements (like @heading[headings], @figure[figures], and @list[lists]) when appropriate instead of defining custom constructs. This lets Typst know (and export) the role a construct plays in the document. See @guides:accessibility[the Accessibility guide] for more details. -- Do not exclusively use contrast, color, format, or layout to communicate an idea. Use text or alternative descriptions instead of or in addition to these elements. -- Wrap all decorative elements without a semantic meaning in @pdf.artifact. -- Do not use images of text. Instead, insert the text directly into your markup. - -Typst currently only supports part one (PDF/UA-1) which is based on PDF 1.7 (2006). When exporting to PDF/UA-1, be aware that you will need to manually provide @math:accessibility[alternative descriptions of mathematics] in natural language. - -New accessibility features were added to PDF 2.0 (2017). When set to PDF 2.0 export, Typst will leverage some of these features. PDF 2.0 and PDF/UA-1, however, are mutually incompatible. For accessible documents, we currently recommend exporting to PDF/UA-1 instead of PDF 2.0 for the additional checks and greater compatibility. The second part of PDF/UA is designed for PDF 2.0, but not yet supported by Typst. - -== PDF/A -Typst optionally supports emitting PDF/A-conformant files. PDF/A files are geared towards maximum compatibility with current and future PDF tooling. They do not rely on difficult-to-implement or proprietary features and contain exhaustive metadata. This makes them suitable for long-term archival. - -The PDF/A Standard has multiple versions (_parts_ in ISO terminology) and most parts have multiple profiles that indicate the file's conformance level. You can target one part and conformance level at a time. Currently, Typst supports these PDF/A output profiles: - -- *PDF/A-1b:* The _basic_ conformance level of ISO 19005-1. This version of PDF/A is based on PDF 1.4 (2001) and results in self-contained, archivable PDF files. As opposed to later parts of the PDF/A standard, transparency is not allowed in PDF/A-1 files. - -- *PDF/A-1a:* This is the _accessible_ conformance level that builds on the basic level PDF/A-1b. To conform to this level, your file must be an accessible _Tagged PDF_ file. Note that accessibility is improved with later parts as not all PDF accessibility features are available for PDF 1.4 files. Furthermore, all text in the file must consist of known Unicode code points. - -- *PDF/A-2b:* The _basic_ conformance level of ISO 19005-2. This version of PDF/A is based on PDF 1.7 (2006) and results in self-contained, archivable PDF files. - -- *PDF/A-2u:* This is the _Unicode-mappable_ conformance level that builds on the basic level A-2b. It also adds rules that all text in the document must consist of known Unicode code points. If possible, always prefer this standard over PDF/A-2b. - -- *PDF/A-2a:* This is the _accessible_ conformance level that builds on the Unicode-mappable level A-2u. This conformance level also adds two requirements: Your file must be an accessible _Tagged PDF_ file. Typst automatically adds tags to help you reach this conformance level. Also pay attention to the Accessibility Sections throughout the reference and the @guides:accessibility[Accessibility Guide] when targeting this conformance level. Finally, PDF/A-2a forbids you from using code points in the #link("https://en.wikipedia.org/wiki/Private_Use_Areas")[Unicode Private Use area]. If you want to build an accessible file, also consider additionally targeting PDF/UA-1, which enables more automatic accessibility checks. - -- *PDF/A-3b:* The _basic_ conformance level of ISO 19005-3. This version of PDF/A is based on PDF 1.7 (2006) and results in archivable PDF files that can contain arbitrary other related files as @pdf.attach[attachments]. The only difference between it and PDF/A-2b is the capability to attach non-PDF/A-conformant files. - -- *PDF/A-3u:* This is the _Unicode-mappable_ conformance level that builds on the basic level A-3b. Just like PDF/A-2b, this requires all text to consist of known Unicode code points. These rules do not apply to attachments. If possible, always prefer this standard over PDF/A-3b. - -- *PDF/A-3a:* This is the _accessible_ conformance level that builds on the Unicode-mappable level A-3u. Just like PDF/A-2a, this requires files to be accessible _Tagged PDF_ and to not use characters from the Unicode Private Use area. Just like before, these rules do not apply to attachments. - -- *PDF/A-4:* The basic conformance level of ISO 19005-4. This version of PDF/A is based on PDF 2.0 (2017) and results in self-contained, archivable PDF files. PDF/A-4 has no parts relating to accessibility. Instead, the topic has been elaborated on more in the dedicated PDF/UA standard. PDF/A-4 files can conform to PDF/UA-2 (currently not supported in Typst). - -- *PDF/A-4f:* The _embedded files_ conformance level that builds on the basic level A-4. Files conforming to this level can contain arbitrary other related files as @pdf.attach[attachments], just as files conforming to part 3 of ISO 19005. The only difference between it and PDF/A-4 is the capability to attach non-PDF/A-conformant files. - -- *PDF/A-4e:* The _engineering_ conformance level that builds on the embedded files level A-4f. Files conforming to this level can contain 3D objects. Typst does not support 3D content, so this is functionally equivalent to PDF/A-4f from a Typst perspective. - -If you want to target PDF/A but are unsure about which particular setting to use, there are some good rules of thumb. First, you must determine your *part,* that's the "version number" of the standard. Ask yourself these questions: - -+ *Does pre-2006 software or equipment need to be able to read my file?* If so, choose part one (PDF/A-1). - -+ *If not, does my file need @pdf.attach[attachments]?* If so, you must choose part three (PDF/A-3) or the embedded files level of part four (PDF/A-4f). - -+ *Does your file need to use features introduced in PDF 2.0?* Currently, use of PDF 2.0 features in Typst is limited to minor improvements in accessibility, e.g. for the @title[title element]. If you can do without these improvements, maximize compatibility by choosing part two (when you don't need attachments) or part three (when you do need attachments). If you rely on PDF 2.0 features, use part four. - -Now, you only need to choose a *conformance level* (the lowercase letter at the end). - -- *If you decided on part one, two, or three,* you should typically choose the accessible conformance level of your part, indicated by a lowercase `a` at the end. If your document is inherently inaccessible, e.g. an artist's portfolio that cannot be boiled down to alternative descriptions, choose conformance level `u` instead. Only if this results in a compiler error, e.g. because you used code points from the Unicode Private Use Area, use the basic level `b`. - -- *If you have chosen part four,* you should choose the basic conformance level (PDF/A-4) except when needing to embed files. - -When choosing between exporting PDF/A and regular PDF, keep in mind that PDF/A files contain additional metadata, and that some readers will prevent the user from modifying a PDF/A file. - -= PDF-specific functionality -Typst exposes PDF-specific functionality in the global `pdf` module. See below for the definitions it contains. - -This module contains some functions without a final API. They are designed to enhance accessibility for documents with complex tables. This includes @pdf.table-summary[`table-summary`], @pdf.header-cell[`header-cell`], and @pdf.data-cell[`data-cell`]. All of these functions will be removed in a future Typst release, either through integration into table functions or through full removal. You can enable these functions by passing `--features a11y-extras` or setting the `TYPST_FEATURES` environment variable to `a11y-extras`. In the web app, these features are not available at this time. diff --git a/docs/content/reference/export/png.typ b/docs/content/reference/export/png.typ deleted file mode 100644 index dee549564ff5..000000000000 --- a/docs/content/reference/export/png.typ +++ /dev/null @@ -1,42 +0,0 @@ -#import "../../../components/index.typ": docs-category - -#show: docs-category.with( - title: "PNG", - description: "Documentation for Typst's PNG export target.", - category: "png", -) - -Instead of creating a PDF, Typst can also directly render pages to PNG raster graphics. PNGs are losslessly compressed images that can contain one page at a time. When exporting a multi-page document, Typst will emit multiple PNGs. PNGs are a good choice when you want to use Typst's output in an image editing software or when you can use none of Typst's other export formats. - -In contrast to Typst's other export formats, PNGs are bound to a specific resolution. When exporting to PNG, you can configure the resolution as pixels per inch (PPI). If the medium you view the PNG on has a finer resolution than the PNG you exported, you will notice a loss of quality. Typst calculates the resolution of your PNGs based on each page's physical dimensions and the PPI. If you need guidance for choosing a PPI value, consider the following: - -- A value of 300 or 600 is typical for desktop printing. -- Professional prints of detailed graphics can go up to 1200 PPI. -- If your document is only viewed at a distance, e.g. a poster, you may choose a smaller value than 300. -- If your document is viewed on screens, a typical PPI value for a smartphone is 400-500. - -Because PNGs only contain a pixel raster, the text within cannot be extracted automatically (without OCR), for example by copy/paste or a screen reader. If you need the text to be accessible, export a PDF or HTML file instead. - -PNGs can have transparent backgrounds. By default, Typst will output a PNG with an opaque white background. You can make the background transparent using `[#set page(fill: none)]`. Learn more on the @page.fill[`page` function's reference page]. - -= Exporting as PNG -== Command Line -Pass `--format png` to the `compile` or `watch` subcommand or provide an output file name that ends with `.png`. - -If your document has more than one page, Typst will create multiple image files. The output file name must then be a template string containing at least one of -- `[{p}]`, which will be replaced by the page number -- `[{0p}]`, which will be replaced by the zero-padded page number (so that all numbers have the same length) -- `[{t}]`, which will be replaced by the total number of pages - -When exporting to PNG, you have the following configuration options: - -- Which resolution to render at by specifying `--ppi` followed by a number of pixels per inch. The default is `144`. - -- Which pages to export by specifying `--pages` followed by a comma-separated list of numbers or dash-separated number ranges. Ranges can be half-open. Example: `2,3,7-9,11-`. - -== Web App -Click "File" > "Export as" > "PNG" or click the downwards-facing arrow next to the quick download button and select "Export as PNG". When exporting to PNG, you have the following configuration options: - -- The resolution at which the pages should be rendered, as a number of pixels per inch. The default is `144`. - -- Which pages to export. Valid options are "All pages", "Current page", and "Custom ranges". Custom ranges are a comma-separated list of numbers or dash-separated number ranges. Ranges can be half-open. Example: `2,3,7-9,11-`. diff --git a/docs/content/reference/export/svg.typ b/docs/content/reference/export/svg.typ deleted file mode 100644 index 7a603fcad775..000000000000 --- a/docs/content/reference/export/svg.typ +++ /dev/null @@ -1,31 +0,0 @@ -#import "../../../components/index.typ": docs-category - -#show: docs-category.with( - title: "SVG", - description: "Documentation for Typst's SVG export target.", - category: "svg", -) - -Instead of creating a PDF, Typst can also directly render pages to scalable vector graphics (SVGs), which are the preferred format for embedding vector graphics in web pages. Like PDF files, SVGs display your document exactly how you have laid it out in Typst. Likewise, they share the benefit of not being bound to a specific resolution. Hence, you can print or view SVG files on any device without incurring a loss of quality. (Note that font printing quality may be better with a PDF.) In contrast to a PDF, an SVG cannot contain multiple pages. When exporting a multi-page document, Typst will emit multiple SVGs. - -SVGs can represent text in two ways: By embedding the text itself and rendering it with the fonts available on the viewer's computer or by embedding the shapes of each glyph in the font used to create the document. To ensure that the SVG file looks the same across all devices it is viewed on, Typst chooses the latter method. This means that the text in the SVG cannot be extracted automatically, for example by copy/paste or a screen reader. If you need the text to be accessible, export a PDF or HTML file instead. - -SVGs can have transparent backgrounds. By default, Typst will output an SVG with an opaque white background. You can make the background transparent using `[#set page(fill: none)]`. Learn more on the @page.fill[`page` function's reference page]. - -= Exporting as SVG -== Command Line -Pass `--format svg` to the `compile` or `watch` subcommand or provide an output file name that ends with `.svg`. - -If your document has more than one page, Typst will create multiple image files. The output file name must then be a template string containing at least one of -- `[{p}]`, which will be replaced by the page number -- `[{0p}]`, which will be replaced by the zero-padded page number (so that all numbers have the same length) -- `[{t}]`, which will be replaced by the total number of pages - -When exporting to SVG, you have the following configuration options: - -- Which pages to export by specifying `--pages` followed by a comma-separated list of numbers or dash-separated number ranges. Ranges can be half-open. Example: `2,3,7-9,11-`. - -== Web App -Click "File" > "Export as" > "SVG" or click the downwards-facing arrow next to the quick download button and select "Export as SVG". When exporting to SVG, you have the following configuration options: - -- Which pages to export. Valid options are "All pages", "Current page", and "Custom ranges". Custom ranges are a comma-separated list of numbers or dash-separated number ranges. Ranges can be half-open. Example: `2,3,7-9,11-`. diff --git a/docs/content/reference/index.typ b/docs/content/reference/index.typ index b88537624fbc..0dd4bc8a65b4 100644 --- a/docs/content/reference/index.typ +++ b/docs/content/reference/index.typ @@ -24,16 +24,7 @@ The second part includes sections ] on all functions, types, and other definitions provided by the _standard library_ of the Typst language. - The definition sections are grouped by topic. For example, if you would like to explore all tools Typst provides to adjust where elements land on the page, you should start in the @reference:layout section. - - = Export - Some of the features in Typst only apply to certain output file formats. - #context if target() == "paged" [ - Starting in the @pdf[PDF] section, you can find chapters for each of the output formats Typst supports. This is where - ] else [ - In the third part, - ] - you find the available format-specific settings and learn what features are available to customize your document for a given format. + The definition sections are grouped by topic. For example, if you would like to explore all tools Typst provides to adjust where elements land on the page, you should start in the @reference:layout section. If instead you'd rather learn more about what formats Typst can export to, you should peruse the @format[Formats] section. = Reading the reference This reference uses a few graphical conventions and labels to let you quickly scan its sections. @@ -59,4 +50,3 @@ #show: paged-heading-offset.with(1) #include "language/index.typ" #include "library/index.typ" -#include "export/index.typ" diff --git a/docs/content/reference/library/format.typ b/docs/content/reference/library/format.typ new file mode 100644 index 000000000000..8650715a9909 --- /dev/null +++ b/docs/content/reference/library/format.typ @@ -0,0 +1,33 @@ +#import "../../../components/index.typ": docs-category, short-or-long, scope + +#show: docs-category.with( + title: "Formats", + description: "Documentation for Typst's export formats.", + category: "format", + scope: scope(std, "format"), + sub-categories: dictionary(format), +) + +Typst documents can be exported to various different export formats. This section documents the available export formats and lists format-specific settings and features. + +Each export format has an associated element through which it can be configured. These elements are defined in the global `format` module. The major formats `pdf` and `html` are additionally directly available in the global scope. + += #short-or-long[Export setstings][Configuring export settings] +There are two ways to specify export settings: + +- At export time, through command line arguments or the web app's "Export & Preview" panel +- In the document, through a @reference:styling:set-rules[set rule] on the format's element + +Below is an example showing how you could change the default @pdf.standard[PDF standard] for a document. Unless specified otherwise (e.g. via a command line argument), a document with this set rule will be exported as a PDF/UA-1. + +```typ +// The document will now default to PDF/UA-1. +#set pdf(standard: "ua-1") +``` + +Similarly, we can write a set rule that makes rendered PNG a bit sharper by default. Here, we have to write @format.png instead of just `png` since the PNG format element is not globally available (as it's a bit more niche of an export format than PDF). + +```typ +// This makes PNGs a bit higher-res. +#set format.png(ppi: 300) +``` diff --git a/docs/content/reference/library/foundations.typ b/docs/content/reference/library/foundations.typ index 01a3aaaa35cc..65935c8983f0 100644 --- a/docs/content/reference/library/foundations.typ +++ b/docs/content/reference/library/foundations.typ @@ -14,7 +14,7 @@ def-target: calc, title: "Calculation", scope: scope(std, "calc"), - items: dictionary(calc).pairs().filter(((k, v)) => type(v) == function), + items: dictionary(calc).filter(v => type(v) == function), description: "Documentation for the `calc` module, which contains definitions for mathematical computation.", docs: [ Module for calculations and processing of numeric values. @@ -26,7 +26,7 @@ name: "std", def-target: std, title: "Standard Library", - items: (), + items: (:), description: "Documentation for the `std` module, which contains all globally accessible items.", docs: [ A module that contains all globally accessible items. @@ -64,7 +64,7 @@ name: "sys", def-target: sys, title: "System", - items: (), + items: (:), description: "Documentation for the `sys` module.", docs: [ Module for system interactions. diff --git a/docs/content/reference/library/index.typ b/docs/content/reference/library/index.typ index d95d0fba33a1..3302f6695849 100644 --- a/docs/content/reference/library/index.typ +++ b/docs/content/reference/library/index.typ @@ -11,3 +11,4 @@ #include "visualize.typ" #include "introspection.typ" #include "data-loading.typ" +#include "format.typ" diff --git a/docs/content/reference/library/math.typ b/docs/content/reference/library/math.typ index 357dfe61e078..2a13578927a1 100644 --- a/docs/content/reference/library/math.typ +++ b/docs/content/reference/library/math.typ @@ -2,7 +2,7 @@ #let math-definitions = dictionary(math) #let math-items(..keys) = { - keys.pos().map(k => (k, math-definitions.at(k))) + keys.pos().map(k => (k, math-definitions.at(k))).to-dict() } #show: docs-category.with( diff --git a/docs/src/example.rs b/docs/src/example.rs index 7990b2b4e637..e63f42ab5d87 100644 --- a/docs/src/example.rs +++ b/docs/src/example.rs @@ -6,6 +6,7 @@ use comemo::Tracked; use either::Either; use typst::diag::{At, FileError, FileResult, SourceResult, Trace, Tracepoint, bail}; use typst::engine::Engine; +use typst::format::{Complete, SpannedValue}; use typst::foundations::{ Args, Array, Bytes, Construct, Content, Context, Datetime, Derived, Duration, NativeElement, Packed, Resolve, ShowFn, Smart, StyleChain, Target, TargetElem, array, @@ -23,7 +24,7 @@ use typst::visualize::{ }; use typst::{Features, Library, LibraryExt, World, WorldExt}; use typst_layout::{Page, PagedDocument}; -use typst_render::RenderOptions; +use typst_render::{PngFormatOptions, RenderOptions}; use typst_utils::{LazyHash, Scalar}; /// Processes a code example in the docs and returns an array of `image` @@ -73,11 +74,8 @@ pub fn compile_example( } let tracepoint = || Tracepoint::Call(None); - let mut pages = warned - .output - .trace(engine.world, tracepoint, raw.span())? - .pages() - .to_vec(); + let doc = warned.output.trace(engine.world, tracepoint, raw.span())?; + let mut pages = doc.pages().to_vec(); if single || zoom.is_some() { pages.truncate(1); @@ -159,9 +157,12 @@ fn trim_page(page: &mut Page, Zoom { x, y, w, h }: &Zoom, styles: StyleChain) { /// Turns a compiled `Page` into a Typst `image` element by rendering it. fn page_to_image(page: Page) -> Content { - let opts = RenderOptions { - pixel_per_pt: Scalar::new(2.0), + // NOTE: This discards format options set by the document. + let opts = RenderOptions:: { render_bleed: false, + format: PngFormatOptions { + pixel_per_pt: SpannedValue::detached(Scalar::new(2.0)), + }, }; let pixmap = typst_render::render(&page, &opts); let format = ImageFormat::Raster(RasterFormat::Pixel(PixelFormat { @@ -303,7 +304,15 @@ impl World for ExampleWorld { } static EXAMPLE_LIBRARY: LazyLock> = LazyLock::new(|| { - let mut lib = Library::builder().with_features(Features::all()).build(); + let mut lib = Library::builder([ + typst_html::FORMAT, + typst_pdf::FORMAT, + typst_svg::FORMAT, + typst_render::FORMAT, + typst_bundle::FORMAT, + ]) + .with_features(Features::all()) + .build(); // Adjust the default look a bit. lib.styles.set(PageElem::width, Smart::Custom(Abs::pt(240.0).into())); diff --git a/docs/src/main.rs b/docs/src/main.rs index 164e500f2e27..4e509ac43c68 100644 --- a/docs/src/main.rs +++ b/docs/src/main.rs @@ -146,16 +146,10 @@ impl Config { /// printing. fn compile_once(world: &DocWorld, config: &mut Config) -> Report { let mut warned = match config.output_format { - OutputFormat::Website => { - let Warned { output, warnings } = typst::compile::(world); - let result = output.and_then(|bundle| export_website(bundle, config)); - Warned { output: result, warnings } - } - OutputFormat::Pdf => { - let Warned { output, warnings } = typst::compile::(world); - let result = output.and_then(|document| export_pdf(&document, config)); - Warned { output: result, warnings } - } + OutputFormat::Website => typst::compile::(world) + .and_then(|bundle| export_website(bundle, config)), + OutputFormat::Pdf => typst::compile::(world) + .and_then(|document| export_pdf(&document, config)), }; if config.open && warned.output.is_ok() { @@ -177,26 +171,30 @@ fn compile_once(world: &DocWorld, config: &mut Config) -> Report { /// Exports the built website, adding a search index, and refreshes the live /// reload server. -fn export_website(mut bundle: Bundle, config: &Config) -> SourceResult<()> { - let index = crate::search::build_search_index(&bundle)?; - let search_path = crate::search::index_path(&bundle).at(Span::detached())?; - Arc::make_mut(&mut bundle.files).insert( - search_path, - BundleFile::Asset(Bytes::new(serde_json::to_vec(&index).unwrap())), - ); - - let options = BundleOptions::default(); - let fs = typst_bundle::export(&bundle, &options)?; - - if let Some(path) = &config.output { - write_virtual_fs(path, &fs); - } - - if let Some(server) = &config.server { - server.set_bundle(bundle, fs); - } - - Ok(()) +fn export_website(mut bundle: Bundle, config: &Config) -> Warned> { + Warned::from(Ok(())) + .and_then(|()| { + let index = crate::search::build_search_index(&bundle)?; + let search_path = crate::search::index_path(&bundle).at(Span::detached())?; + Arc::make_mut(&mut bundle.files).insert( + search_path, + BundleFile::Asset(Bytes::new(serde_json::to_vec(&index).unwrap())), + ); + + Ok(BundleOptions::default()) + }) + .and_then(|options| typst_bundle::export(&bundle, &options)) + .and_then(|fs| { + if let Some(path) = &config.output { + write_virtual_fs(path, &fs); + } + + if let Some(server) = &config.server { + server.set_bundle(bundle, fs); + } + + Ok(()) + }) } /// Writes a bundle's files to disk. @@ -212,15 +210,16 @@ fn write_virtual_fs(root: &Path, fs: &VirtualFs) { } /// Exports a document to PDF and writes it to disk. -fn export_pdf(document: &PagedDocument, config: &Config) -> SourceResult<()> { - let data = typst_pdf::pdf(document, &PdfOptions::default())?; - if let Some(path) = &config.output { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).unwrap(); +fn export_pdf(document: &PagedDocument, config: &Config) -> Warned> { + typst_pdf::pdf(document, &PdfOptions::default()).and_then(|data| { + if let Some(path) = &config.output { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, data).unwrap(); } - std::fs::write(path, data).unwrap(); - } - Ok(()) + Ok(()) + }) } /// Acquires the output stream for user-facing messages. diff --git a/docs/src/reflect.rs b/docs/src/reflect.rs index 73da40db0dcc..b70af4e24631 100644 --- a/docs/src/reflect.rs +++ b/docs/src/reflect.rs @@ -26,10 +26,29 @@ use unscanny::Scanner; use crate::world::REPO_ROOT; +/// A value that can have a scope. +pub enum ToScope { + Module(Module), + Type(Type), + Func(Func), +} + +cast! { + ToScope, + m: Module => Self::Module(m), + t: Type => Self::Type(t), + f: Func => Self::Func(f), +} + /// Provides details about a binding in a module. #[func] -pub fn binding(module: Module, name: EcoString) -> Option { - let binding = module.scope().get(&name)?; +pub fn binding(scope: ToScope, name: EcoString) -> Option { + let scope = match &scope { + ToScope::Module(module) => module.scope(), + ToScope::Type(ty) => ty.scope(), + ToScope::Func(func) => func.scope()?, + }; + let binding = scope.get(&name)?; Some(dict! { "category" => binding.category().map(|c| c.name()), "feature" => binding.feature().map(|f| f.to_string()), diff --git a/docs/src/world.rs b/docs/src/world.rs index 57d9f688feed..fd3f95e480fb 100644 --- a/docs/src/world.rs +++ b/docs/src/world.rs @@ -199,7 +199,15 @@ pub static FONTS: LazyLock<(LazyHash, Vec)> = LazyLock::new(|| { /// - an `stdx` module with various utilities /// - a few patched show rules fn library(is_dev_version: bool) -> Library { - let mut lib = Library::builder().with_features(Features::all()).build(); + let mut lib = Library::builder([ + typst_html::FORMAT, + typst_pdf::FORMAT, + typst_svg::FORMAT, + typst_render::FORMAT, + typst_bundle::FORMAT, + ]) + .with_features(Features::all()) + .build(); let scope = lib.global.scope_mut(); scope.define("stdx", stdx_module(is_dev_version)); lib.rules.replace(Target::Html, PATCHED_LINK_RULE); diff --git a/tests/fuzz/Cargo.toml b/tests/fuzz/Cargo.toml index 270dc54b32cd..5f1ce9001652 100644 --- a/tests/fuzz/Cargo.toml +++ b/tests/fuzz/Cargo.toml @@ -10,6 +10,7 @@ cargo-fuzz = true [dependencies] typst = { workspace = true } typst-assets = { workspace = true, features = ["fonts"] } +typst-bundle = { workspace = true } typst-pdf = { workspace = true } typst-svg = { workspace = true } typst-layout = { workspace = true } diff --git a/tests/fuzz/src/bin/paged.rs b/tests/fuzz/src/bin/paged.rs index c04f85638e40..af9ff33ce6d0 100644 --- a/tests/fuzz/src/bin/paged.rs +++ b/tests/fuzz/src/bin/paged.rs @@ -1,18 +1,24 @@ #![no_main] use libfuzzer_sys::fuzz_target; +use typst::model::Document; use typst_fuzz::FuzzWorld; use typst_layout::PagedDocument; use typst_pdf::PdfOptions; -use typst_render::RenderOptions; -use typst_svg::SvgOptions; +use typst_render::{PngFormat, RenderOptions}; +use typst_svg::{SvgFormat, SvgOptions}; fuzz_target!(|text: &str| { let world = FuzzWorld::new(text); if let Ok(document) = typst::compile::(&world).output { if let Some(page) = document.pages().first() { - std::hint::black_box(typst_render::render(page, &RenderOptions::default())); - std::hint::black_box(typst_svg::svg(page, &SvgOptions::default())); + let png_options = + RenderOptions::default().resolve(document.options().get::()); + std::hint::black_box(typst_render::render(page, &png_options)); + + let svg_options = + SvgOptions::default().resolve(document.options().get::()); + std::hint::black_box(typst_svg::svg(page, &svg_options)); } _ = std::hint::black_box(typst_pdf::pdf(&document, &PdfOptions::default())); } diff --git a/tests/fuzz/src/lib.rs b/tests/fuzz/src/lib.rs index 19e7989111a8..59595bb9e57b 100644 --- a/tests/fuzz/src/lib.rs +++ b/tests/fuzz/src/lib.rs @@ -3,7 +3,7 @@ use typst::foundations::{Bytes, Datetime, Duration}; use typst::syntax::{FileId, Source}; use typst::text::{Font, FontBook}; use typst::utils::LazyHash; -use typst::{Library, LibraryExt, World}; +use typst::{Features, Library, LibraryExt, World}; pub struct FuzzWorld { library: LazyHash, @@ -18,7 +18,17 @@ impl FuzzWorld { let font = Font::new(Bytes::new(data), 0).unwrap(); let book = FontBook::from_fonts([&font]); Self { - library: LazyHash::new(Library::default()), + library: LazyHash::new( + Library::builder([ + typst_html::FORMAT, + typst_pdf::FORMAT, + typst_svg::FORMAT, + typst_render::FORMAT, + typst_bundle::FORMAT, + ]) + .with_features(Features::all()) + .build(), + ), book: LazyHash::new(book), font, source: Source::detached(text), diff --git a/tests/src/args.rs b/tests/src/args.rs index 1d3894c32d69..a1debc12230b 100644 --- a/tests/src/args.rs +++ b/tests/src/args.rs @@ -38,7 +38,7 @@ pub struct CliArguments { /// /// Does not affect the comparison or the reference image. #[arg(short, long, default_value_t = 1.0)] - pub scale: f32, + pub scale: f64, /// Displays the syntax tree before running tests. /// /// Note: This is ignored if using '--syntax-compare'. diff --git a/tests/src/collect.rs b/tests/src/collect.rs index 6fc48a523df7..3ea84daff562 100644 --- a/tests/src/collect.rs +++ b/tests/src/collect.rs @@ -9,7 +9,6 @@ use ecow::EcoString; use rustc_hash::{FxHashMap, FxHashSet}; use typst::foundations::Target; use typst::{Feature, Features}; -use typst_pdf::PdfStandard; use typst_syntax::{is_id_continue, is_ident, is_newline}; use unscanny::Scanner; @@ -97,7 +96,6 @@ pub struct Attrs { pub large: bool, pub empty: bool, pub features: Option, - pub pdf_standard: Vec, /// Tolerance for image comparisons. Render tests are not 100% reproducible. /// By default, we allow a byte difference of 1, but in rare cases, we need /// to increase it. This can for example happen due to cross-platform @@ -692,9 +690,7 @@ impl<'a> Parser<'a> { let mut stages = TestStages::empty(); let mut flags = AttrFlags::empty(); let mut features = None; - let mut pdf_standard = Vec::new(); let mut tolerance = None; - while !self.s.eat_if("---") { let attr_name = self.s.eat_while(is_id_continue); let mut attr_params = None; @@ -713,16 +709,6 @@ impl<'a> Parser<'a> { "paged" => self.set_attr(attr_name, &mut stages, TestStages::PAGED), "pdf" => self.set_attr(attr_name, &mut stages, TestStages::PDF), "pdftags" => self.set_attr(attr_name, &mut stages, TestStages::PDFTAGS), - "pdfstandard" => { - let Some(params) = attr_params.take() else { - self.error("expected parameter for `pdfstandard`"); - continue; - }; - pdf_standard = - self.parse_attr_param_list(params, "pdf standard", |s| { - serde_yaml::from_str(s) - }); - } "tolerance" => { let Some(param) = attr_params.take() else { self.error("expected parameter for `tolerance`"); @@ -782,7 +768,6 @@ impl<'a> Parser<'a> { large: flags.contains(AttrFlags::LARGE), empty: flags.contains(AttrFlags::EMPTY), features, - pdf_standard, stages, tolerance, } diff --git a/tests/src/output.rs b/tests/src/output.rs index f927014f55b3..12c123b12f07 100644 --- a/tests/src/output.rs +++ b/tests/src/output.rs @@ -11,22 +11,22 @@ use hayro_svg::{RenderCache, SvgRenderSettings}; use indexmap::IndexMap; use rustc_hash::FxBuildHasher; use tiny_skia as sk; -use typst::diag::{At, SourceResult, StrResult, bail}; +use typst::diag::{At, SourceResult, StrResult, Warned, bail}; use typst::foundations::{Content, SequenceElem, Smart}; use typst::layout::{Abs, Frame, FrameItem, Transform}; -use typst::model::ParbreakElem; +use typst::model::{Document, ParbreakElem}; use typst::text::SpaceElem; use typst::visualize::Color; use typst_bundle::{BundleOptions, VirtualFs}; -use typst_html::{HtmlDocument, HtmlOptions}; +use typst_html::{HtmlDocument, HtmlFormatOptions, HtmlOptions}; use typst_layout::PagedDocument; -use typst_pdf::{PdfOptions, PdfStandard, PdfStandards}; -use typst_render::RenderOptions; -use typst_svg::SvgOptions; +use typst_pdf::{PdfFormatOptions, PdfOptions, PdfStandard, PdfStandards}; +use typst_render::{PngFormatOptions, RenderOptions}; +use typst_svg::{SvgFormatOptions, SvgOptions}; use typst_syntax::Span; use typst_utils::Scalar; -use crate::collect::{Test, TestOutput}; +use crate::collect::TestOutput; use crate::report::{Diff, File, Old, ReportFile}; use crate::{pdftags, report}; @@ -140,7 +140,7 @@ pub trait OutputType: Sized { fn is_empty(doc: &Self::Doc, live: &Self::Live) -> bool; /// Produces the live output. - fn make_live(test: &Test, doc: &Self::Doc) -> SourceResult; + fn make_live(doc: &Self::Doc) -> Warned>; /// Converts the live output to bytes that can be saved to disk. fn save_live(doc: &Self::Doc, live: &Self::Live) -> impl AsRef<[u8]>; @@ -189,8 +189,8 @@ impl OutputType for Render { is_empty_paged_document(doc) } - fn make_live(_: &Test, doc: &Self::Doc) -> SourceResult { - Ok(render(doc, 1.0)) + fn make_live(doc: &Self::Doc) -> Warned> { + Ok(render(doc, 1.0)).into() } fn save_live(doc: &Self::Doc, live: &Self::Live) -> impl AsRef<[u8]> { @@ -200,7 +200,7 @@ impl OutputType for Render { let slot; let scale = crate::ARGS.scale; if scale != 1.0 { - slot = render(doc, scale); + slot = render(doc, scale as f32); pixmap_live = &slot; } pixmap_live.encode_png().unwrap() @@ -244,16 +244,19 @@ impl OutputType for Pdf { is_empty_paged_document(doc) } - fn make_live(test: &Test, doc: &Self::Doc) -> SourceResult { + fn make_live(doc: &Self::Doc) -> Warned> { // Always run the default PDF export and PDF/UA-1 export, to detect // crashes, since there are quite a few different code paths involved. // If another standard is specified in the test, run that as well. - let default_pdf = generate_pdf(doc, &[]); - let ua1_pdf = generate_pdf(doc, &[PdfStandard::Ua_1]); - match test.attrs.pdf_standard.as_slice() { - &[] => default_pdf, - &[PdfStandard::Ua_1] => ua1_pdf, - others => generate_pdf(doc, others), + let default_pdf = generate_pdf(doc, Some(&[])); + let ua1_pdf = generate_pdf(doc, Some(&[PdfStandard::UA_1])); + let doc_opts = doc.options().get::(); + let standards = doc_opts.standard.v.standards().collect::>(); + match *standards.as_slice() { + [] => default_pdf, + [PdfStandard::V_1_7, PdfStandard::UA_1] => ua1_pdf, + // Use the standards specified by the document. + _ => generate_pdf(doc, None), } } @@ -341,18 +344,25 @@ impl HashOutputType for Pdf { const INDEX: usize = 0; } -fn generate_pdf(doc: &PagedDocument, standards: &[PdfStandard]) -> SourceResult> { - let options = pdf_options(standards)?; +fn generate_pdf( + doc: &PagedDocument, + standards: Option<&[PdfStandard]>, +) -> Warned>> { + let options = pdf_options(standards); typst_pdf::pdf(doc, &options) } -fn pdf_options(standards: &[PdfStandard]) -> SourceResult { - let standards = PdfStandards::new(standards).at(Span::detached())?; - Ok(PdfOptions { - standards, +fn pdf_options(standards: Option<&[PdfStandard]>) -> PdfOptions { + let standard = standards + .map(|s| PdfStandards::new(s.iter().copied())) + .transpose() + .unwrap(); + // TODO: Maybe enable pretty here too? + PdfOptions { creator: Smart::Custom(Some("Typst Test Runner".into())), + format: PdfFormatOptions { standard, ..Default::default() }, ..Default::default() - }) + } } pub struct Pdftags; @@ -367,8 +377,8 @@ impl OutputType for Pdftags { live.is_empty() } - fn make_live(_: &Test, doc: &Self::Doc) -> SourceResult { - pdftags::format(doc).at(Span::detached()) + fn make_live(doc: &Self::Doc) -> Warned> { + pdftags::format(doc).at(Span::detached()).into() } fn save_live(_: &Self::Doc, live: &Self::Live) -> impl AsRef<[u8]> { @@ -404,9 +414,12 @@ impl OutputType for Svg { is_empty_paged_document(doc) } - fn make_live(_: &Test, doc: &Self::Doc) -> SourceResult { - let options = SvgOptions { pretty: true, ..Default::default() }; - Ok(typst_svg::svg_merged(doc, &options, Abs::pt(1.0))) + fn make_live(doc: &Self::Doc) -> Warned> { + let options = SvgOptions { + format: SvgFormatOptions { pretty: Some(true) }, + ..Default::default() + }; + Ok(typst_svg::svg_merged(doc, &options, Abs::pt(1.0))).into() } fn save_live(_: &Self::Doc, live: &Self::Live) -> impl AsRef<[u8]> { @@ -453,9 +466,9 @@ impl OutputType for Html { live == EMPTY_HTML_DOC } - fn make_live(_: &Test, doc: &Self::Doc) -> SourceResult { - let options = HtmlOptions { pretty: true }; - typst_html::html(doc, &options) + fn make_live(doc: &Self::Doc) -> Warned> { + let options = HtmlOptions { format: HtmlFormatOptions { pretty: Some(true) } }; + typst_html::html(doc, &options).into() } fn save_live(_: &Self::Doc, live: &Self::Live) -> impl AsRef<[u8]> { @@ -491,16 +504,18 @@ impl OutputType for Bundle { live.is_empty() } - fn make_live(test: &Test, doc: &Self::Doc) -> SourceResult { - let standards = test.attrs.pdf_standard.as_slice(); + fn make_live(doc: &Self::Doc) -> Warned> { let options = BundleOptions { - html: HtmlOptions { pretty: true }, - pdf: pdf_options(standards)?, + html: HtmlOptions { format: HtmlFormatOptions { pretty: Some(true) } }, + pdf: pdf_options(None), png: RenderOptions { - pixel_per_pt: Scalar::new(1.0), + format: PngFormatOptions { pixel_per_pt: Some(Scalar::new(1.0)) }, + ..Default::default() + }, + svg: SvgOptions { + format: SvgFormatOptions { pretty: Some(true) }, ..Default::default() }, - svg: SvgOptions { pretty: true, ..Default::default() }, }; typst_bundle::export(doc, &options) } @@ -593,7 +608,9 @@ fn render(document: &PagedDocument, pixel_per_pt: f32) -> sk::Pixmap { let gap = Abs::pt(1.0); let opts = typst_render::RenderOptions { - pixel_per_pt: Scalar::new(pixel_per_pt as f64), + format: PngFormatOptions { + pixel_per_pt: Some(Scalar::new(pixel_per_pt as f64)), + }, render_bleed: false, }; let mut pixmap = diff --git a/tests/src/run.rs b/tests/src/run.rs index f04952cc7758..edaabd06c6b9 100644 --- a/tests/src/run.rs +++ b/tests/src/run.rs @@ -473,9 +473,9 @@ impl<'a> Runner<'a> { /// Run test for a specific output format, and save the live output to disk. fn run_test(&mut self, doc: Option<&T::Doc>) -> Option { let doc = doc?; - let live = T::make_live(self.test, doc); + let Warned { output, warnings } = T::make_live(doc); - if let Err(errors) = &live { + if let Err(errors) = &output { if errors.is_empty() { log!(self, "no document, but also no errors"); } @@ -485,7 +485,11 @@ impl<'a> Runner<'a> { } } - live.ok() + for warning in &warnings { + self.check_diagnostic(NoteKind::Warning, warning, T::OUTPUT); + } + + output.ok() } fn save_live<'d, T: OutputType>( diff --git a/tests/src/world.rs b/tests/src/world.rs index 17e889a1d5f5..b07051a6d4ec 100644 --- a/tests/src/world.rs +++ b/tests/src/world.rs @@ -211,7 +211,15 @@ fn library() -> Library { // Set page width to 120pt with 10pt margins, so that the inner page is // exactly 100pt wide. Page height is unbounded and font size is 10pt so // that it multiplies to nice round numbers. - let mut lib = Library::builder().with_features(Features::all()).build(); + let mut lib = Library::builder([ + typst_html::FORMAT, + typst_pdf::FORMAT, + typst_svg::FORMAT, + typst_render::FORMAT, + typst_bundle::FORMAT, + ]) + .with_features(Features::all()) + .build(); // Hook up helpers into the global scope. lib.global.scope_mut().define("check", check_module()); diff --git a/tests/suite/format/format.typ b/tests/suite/format/format.typ new file mode 100644 index 000000000000..f06c710665e0 --- /dev/null +++ b/tests/suite/format/format.typ @@ -0,0 +1,5 @@ +--- format-options-in-container paged --- +#block[ + // Error: 4-27 format set rules are not allowed inside of containers + #set html(pretty: false) +] diff --git a/tests/suite/format/pages.typ b/tests/suite/format/pages.typ new file mode 100644 index 000000000000..282c28004bb4 --- /dev/null +++ b/tests/suite/format/pages.typ @@ -0,0 +1,31 @@ +--- format-options-single-page-range paged empty --- +#set pdf(tagged: false, pages: 3) +#set pdf(tagged: false, pages: (from: 3)) +#set pdf(tagged: false, pages: (to: 5)) +#set pdf(tagged: false, pages: (from: 3, to: 5)) + +--- format-options-multiple-page-ranges paged empty --- +#set pdf(tagged: false, pages: (5, 7, 9)) +#set pdf(tagged: false, pages: ((to: 3), 5, (from: 7, to: 8), (from: 10))) + +--- format-options-page-range-untagged-warnings pdf empty --- +// Warning: exporting a page range disables PDF tagging +// Hint: the resulting PDF will be inaccessible +// Hint: set `pdf(tagged: false)` to silence this warning +#set pdf(pages: 1) + +--- format-options-page-nr-string eval --- +// Error: 17-20 expected integer, dictionary, array, or none, found string +#set pdf(pages: "3") + +--- format-options-page-range-str eval --- +// Error: 17-22 expected integer, dictionary, array, or none, found string +#set pdf(pages: "3-5") + +--- format-options-page-nr-zero eval --- +// Error: 17-18 page numbers start at one +#set pdf(pages: 0) + +--- format-options-page-range-inverted eval --- +// Error: 17-33 start of page range cannot be larger than end +#set pdf(pages: (from: 5, to: 2)) diff --git a/tests/suite/layout/grid/headers.typ b/tests/suite/layout/grid/headers.typ index cdc8e9c407e8..4bc8ac755d25 100644 --- a/tests/suite/layout/grid/headers.typ +++ b/tests/suite/layout/grid/headers.typ @@ -1,4 +1,5 @@ ---- grid-headers paged pdftags pdfstandard(ua-1) --- +--- grid-headers paged pdftags --- +#set pdf(standard: "ua-1") #set page(width: auto, height: 12em) #table( columns: 5, diff --git a/tests/suite/layout/line-numbers.typ b/tests/suite/layout/line-numbers.typ index caf96bb0eabd..6e4e3ba4b1c2 100644 --- a/tests/suite/layout/line-numbers.typ +++ b/tests/suite/layout/line-numbers.typ @@ -257,7 +257,8 @@ A $ x $ B ---- line-numbers-pdf-2-0 pdf pdfstandard(2.0) --- +--- line-numbers-pdf-2-0 pdf --- +#set pdf(standard: "2.0") #set page(margin: (left: 2.5em)) #set par.line(numbering: "1") diff --git a/tests/suite/model/bibliography.typ b/tests/suite/model/bibliography.typ index d6bd3bcf71c2..04d4b20fbecc 100644 --- a/tests/suite/model/bibliography.typ +++ b/tests/suite/model/bibliography.typ @@ -1,6 +1,7 @@ // Test citations and bibliographies. ---- bibliography-basic paged html pdftags pdfstandard(ua-1) --- +--- bibliography-basic paged html pdftags --- +#set pdf(standard: "ua-1") #show: it => context { set page(width: 200pt) if target() == "paged"; it } = Details diff --git a/tests/suite/model/enum.typ b/tests/suite/model/enum.typ index 6ca40c8bbf7f..590877b78db0 100644 --- a/tests/suite/model/enum.typ +++ b/tests/suite/model/enum.typ @@ -3,7 +3,8 @@ --- enum-function-call paged --- #enum[Embrace][Extend][Extinguish] ---- enum-number-override-nested paged pdftags pdfstandard(ua-1) --- +--- enum-number-override-nested paged pdftags --- +#set pdf(standard: "ua-1") 0. Before first! 1. First. 2. Indented diff --git a/tests/suite/model/figure.typ b/tests/suite/model/figure.typ index d9dd35c23c21..bb843bac6f79 100644 --- a/tests/suite/model/figure.typ +++ b/tests/suite/model/figure.typ @@ -1,6 +1,7 @@ // Test figures. ---- figure-basic paged pdftags pdfstandard(ua-1) --- +--- figure-basic paged pdftags --- +#set pdf(standard: "ua-1") #set page(width: 150pt) #set figure(numbering: "I") diff --git a/tests/suite/model/heading.typ b/tests/suite/model/heading.typ index 215a0d7484a7..c6645cec4fb4 100644 --- a/tests/suite/model/heading.typ +++ b/tests/suite/model/heading.typ @@ -82,7 +82,8 @@ comment spans lines ===== Heading 🌍 #heading(level: 5)[Heading] ---- heading-offset paged pdftags pdfstandard(ua-1) --- +--- heading-offset paged pdftags --- +#set pdf(standard: "ua-1") // Test setting the starting offset. #set heading(numbering: "1.1") #show heading.where(level: 2): set text(blue) diff --git a/tests/suite/model/link.typ b/tests/suite/model/link.typ index f8e4efaecbe7..0244d57125b7 100644 --- a/tests/suite/model/link.typ +++ b/tests/suite/model/link.typ @@ -1,6 +1,7 @@ // Test hyperlinking. ---- link-basic paged html pdftags pdfstandard(ua-1) --- +--- link-basic paged html pdftags --- +#set pdf(standard: "ua-1") // Link syntax. https://example.com/ diff --git a/tests/suite/model/list.typ b/tests/suite/model/list.typ index ddde8758a242..bae2bc63ac18 100644 --- a/tests/suite/model/list.typ +++ b/tests/suite/model/list.typ @@ -28,7 +28,8 @@ _Shopping list_ - Top-level indent - is fine. ---- list-indent-specifics paged pdftags pdfstandard(ua-1) --- +--- list-indent-specifics paged pdftags --- +#set pdf(standard: "ua-1") - A - B - C diff --git a/tests/suite/model/outline.typ b/tests/suite/model/outline.typ index 4604d3612f8b..45253f8acdd0 100644 --- a/tests/suite/model/outline.typ +++ b/tests/suite/model/outline.typ @@ -1,4 +1,5 @@ ---- outline-spacing paged pdftags pdfstandard(ua-1) --- +--- outline-spacing paged pdftags --- +#set pdf(standard: "ua-1") #set heading(numbering: "1.a.") #set outline.entry(fill: none) #show outline.entry.where(level: 1): set block(above: 1.2em) diff --git a/tests/suite/model/par.typ b/tests/suite/model/par.typ index 624936b82037..cd47665f2d02 100644 --- a/tests/suite/model/par.typ +++ b/tests/suite/model/par.typ @@ -1,6 +1,7 @@ // Test configuring paragraph properties. ---- par-basic paged pdftags pdfstandard(ua-1) --- +--- par-basic paged pdftags --- +#set pdf(standard: "ua-1") #set page(width: 250pt, height: 120pt) But, soft! what light through yonder window breaks? It is the east, and Juliet @@ -431,7 +432,8 @@ Even the first. + D ---- par-first-line-indent-all-terms paged pdftags pdfstandard(ua-1) --- +--- par-first-line-indent-all-terms paged pdftags --- +#set pdf(standard: "ua-1") #show terms.where(tight: false): set terms(spacing: 1.2em) #set terms(hanging-indent: 10pt) #set par( diff --git a/tests/suite/model/quote.typ b/tests/suite/model/quote.typ index 873237712a53..0acf4f236527 100644 --- a/tests/suite/model/quote.typ +++ b/tests/suite/model/quote.typ @@ -1,6 +1,7 @@ // Test the quote element. ---- quote-dir-author-pos paged pdftags pdfstandard(ua-1) --- +--- quote-dir-author-pos paged pdftags --- +#set pdf(standard: "ua-1") // Text direction affects author positioning #set text(font: ("Libertinus Serif", "Noto Sans Arabic")) And I quote: #quote(attribution: [René Descartes])[cogito, ergo sum]. @@ -8,7 +9,8 @@ And I quote: #quote(attribution: [René Descartes])[cogito, ergo sum]. #set text(lang: "ar") #quote(attribution: [عالم])[مرحبًا] ---- quote-dir-align paged pdftags pdfstandard(ua-1) --- +--- quote-dir-align paged pdftags --- +#set pdf(standard: "ua-1") // Text direction affects block alignment #set text(font: ("Libertinus Serif", "Noto Sans Arabic")) #set quote(block: true) diff --git a/tests/suite/model/terms.typ b/tests/suite/model/terms.typ index 77a240c4712b..a67939b43b5d 100644 --- a/tests/suite/model/terms.typ +++ b/tests/suite/model/terms.typ @@ -1,6 +1,7 @@ // Test term list. ---- terms-constructor paged pdftags pdfstandard(ua-1) --- +--- terms-constructor paged pdftags --- +#set pdf(standard: "ua-1") // Test with constructor. #terms( terms.item([One], [First]), diff --git a/tests/suite/model/title.typ b/tests/suite/model/title.typ index f3f1660b4f29..2654373968f3 100644 --- a/tests/suite/model/title.typ +++ b/tests/suite/model/title.typ @@ -1,6 +1,7 @@ // Test title element. ---- title paged html pdftags pdfstandard(ua-1) --- +--- title paged html pdftags --- +#set pdf(standard: "ua-1") #set document(title: [My title]) #title() = A level one heading diff --git a/tests/suite/pdf/validation.typ b/tests/suite/pdf/validation.typ index 288515237c5b..bea826c6b20b 100644 --- a/tests/suite/pdf/validation.typ +++ b/tests/suite/pdf/validation.typ @@ -1,19 +1,23 @@ ---- pdf-validation-multiple-standards pdf pdfstandard(a-2a, ua-1) --- +--- pdf-validation-multiple-standards pdf --- +#set pdf(standard: ("a-2a", "ua-1")) #set document(date: datetime(year: 1970, month: 1, day: 1)) Hello ---- pdf-validation-version-incompatible pdf pdfstandard(1.4, a-4) --- -// Error: PDF 1.4 is not compatible with PDF/A-4 -// Hint: PDF/A-4 requires version PDF 2.0 +--- pdf-validation-version-incompatible eval --- +// Error: 20-34 PDF 1.4 is not compatible with PDF/A-4 +// Hint: 20-34 PDF/A-4 requires version PDF 2.0 +#set pdf(standard: ("1.4", "a-4")) Hello ---- pdf-validation-standards-incompatible pdf pdfstandard(ua-1, a-4) --- -// Error: PDF/A-4 and PDF/UA-1 are mutually incompatible because they do not have any overlapping PDF versions -// Hint: PDF/A-4 requires version PDF 2.0 -// Hint: PDF/UA-1 requires a version between PDF 1.4 and PDF 1.7 +--- pdf-validation-standards-incompatible eval --- +// Error: 20-35 PDF/A-4 and PDF/UA-1 are mutually incompatible because they do not have any overlapping PDF versions +// Hint: 20-35 PDF/A-4 requires version PDF 2.0 +// Hint: 20-35 PDF/UA-1 requires a version between PDF 1.4 and PDF 1.7 +#set pdf(standard: ("ua-1", "a-4")) Hello ---- pdf-validation-min-feature-version-unsatisfiable pdf pdfstandard(ua-1, a-1a) --- +--- pdf-validation-min-feature-version-unsatisfiable pdf --- +#set pdf(standard: ("ua-1", "a-1a")) #set document(date: datetime(year: 1970, month: 1, day: 1)) #table( // Error: 3-37 PDF/UA-1 error: table header cell cannot be accessibly tagged in PDF 1.4 files @@ -22,22 +26,30 @@ Hello pdf.header-cell(scope: "row")[brr] ) ---- pdf-validation-missing-feature-single-version pdf pdfstandard(1.4, ua-1) --- +--- pdf-validation-missing-feature-single-version pdf --- +#set pdf(standard: ("1.4", "ua-1")) // Error: PDF/UA-1 error: headers and footers cannot be made accessible in PDF 1.4 files // Hint: set the version to PDF 1.7 #set page(header: [Hi]) ---- pdf-validation-missing-feature-multiple-versions pdf pdfstandard(1.4, ua-1) --- +--- pdf-validation-missing-feature-multiple-versions pdf --- +#set pdf(standard: ("1.4", "ua-1")) // Error: PDF/UA-1 error: links and other annotations cannot be navigated accessibly in PDF 1.4 files // Hint: select a version between PDF 1.5 and PDF 1.7 https://typst.app/ ---- pdf-validation-tofu pdf pdfstandard(ua-1) --- +--- pdf-validation-page-range-and-tagged pdf --- +// Error: 2-33 cannot enable tagged PDF and export a page range +#set pdf(pages: 1, tagged: true) + +--- pdf-validation-tofu pdf --- +#set pdf(standard: "ua-1") // Error: 1-2 PDF/UA-1 error: the text `"ግ"` could not be displayed with font `"Libertinus Serif"` // Hint: 1-2 try using a different font ግ ---- pdf-validation-tofu-in-svg pdf pdfstandard(ua-1) --- +--- pdf-validation-tofu-in-svg pdf --- +#set pdf(standard: "ua-1") // A spanless error without a font name is kinda bad, but this used to be a // crash, so it's already an improvement. @@ -52,12 +64,14 @@ https://typst.app/ ```.text ), alt: "Geʽez letter") ---- pdf-validation-bundle bundle pdfstandard(ua-1) --- +--- pdf-validation-bundle bundle --- +#set pdf(standard: "ua-1") #document( "hi.pdf", // Error: 13-28 PDF/UA-1 error: cannot combine underline, overline, or strike underline(overline[Hello]), ) ---- issue-8383-headers-pdf-ua pdf pdfstandard(ua-1) --- +--- issue-8383-headers-pdf-ua pdf --- +#set pdf(standard: "ua-1") #set page(header: [A]) diff --git a/tests/suite/pdftags/break.typ b/tests/suite/pdftags/break.typ index 3a1db330431d..f8a3ef6ae664 100644 --- a/tests/suite/pdftags/break.typ +++ b/tests/suite/pdftags/break.typ @@ -28,6 +28,6 @@ Foo #quote($$ + parbreak()) --- issue-7257-break-tags-show-par-none pdftags --- #show par: none #show heading: v(0pt) + [A] -// Error: 3-6 internal error: tags weren't properly closed (occurred at crates/typst-pdf/src/tags/tree/build.rs:187:9) +// Error: 3-6 internal error: tags weren't properly closed (occurred at crates/typst-pdf/src/tags/tree/build.rs:191:9) // Hint: 3-6 please report this as a bug #[= A]B diff --git a/tests/suite/pdftags/deco.typ b/tests/suite/pdftags/deco.typ index 00678270659a..413344fabd67 100644 --- a/tests/suite/pdftags/deco.typ +++ b/tests/suite/pdftags/deco.typ @@ -1,4 +1,5 @@ ---- deco-tags-underline pdftags pdfstandard(ua-1) --- +--- deco-tags-underline pdftags --- +#set pdf(standard: "ua-1") #show: underline.with(stroke: red) // The content in this paragraph is grouped into one span tag with the @@ -8,54 +9,66 @@ red underlined text red underlined text ---- deco-tags-different-color pdftags pdfstandard(ua-1) --- +--- deco-tags-different-color pdftags --- +#set pdf(standard: "ua-1") #show: underline.with(stroke: red) red underlined text #show: underline.with(stroke: blue) blue underlined text ---- deco-tags-different-stroke-thickness pdftags pdfstandard(ua-1) --- +--- deco-tags-different-stroke-thickness pdftags --- +#set pdf(standard: "ua-1") #show: underline.with(stroke: 2pt) thick underlined #show: underline.with(stroke: 1pt) thin underlined ---- deco-tags-different-type pdftags pdfstandard(ua-1) --- +--- deco-tags-different-type pdftags --- +#set pdf(standard: "ua-1") #underline[underlined]\ #overline[overlined]\ #strike[striked]\ ---- deco-tags-multiple-styles pdftags pdfstandard(ua-1) --- +--- deco-tags-multiple-styles pdftags --- +#set pdf(standard: "ua-1") #show: underline // Error: 2-16 PDF/UA-1 error: cannot combine underline, overline, or strike #show: overline text with a bunch of lines ---- deco-tags-highlight-basic pdftags pdfstandard(ua-1) --- +--- deco-tags-highlight-basic pdftags --- +#set pdf(standard: "ua-1") A #highlight[highlighted] alksjdflk asdjlkfj alskdj word. ---- deco-tags-subscript-basic pdftags pdfstandard(ua-1) --- +--- deco-tags-subscript-basic pdftags --- +#set pdf(standard: "ua-1") CO#sub[2] emissions. A2#sub[hex] ---- deco-tags-superscript-basic pdftags pdfstandard(ua-1) --- +--- deco-tags-superscript-basic pdftags --- +#set pdf(standard: "ua-1") CI#super[-] has a negative charge. ---- deco-tags-script-custom-baseline pdftags pdfstandard(ua-1) --- +--- deco-tags-script-custom-baseline pdftags --- +#set pdf(standard: "ua-1") // NOTE: the baseline shift values attribute is inverted. #set sub(baseline: 2.5pt) #set super(baseline: -9.5pt) #sub[sub] #super[super] ---- deco-tags-emph-basic pdftags pdfstandard(ua-1) --- +--- deco-tags-emph-basic pdftags --- +#set pdf(standard: "ua-1") Cats are _cute_ animals. ---- deco-tags-strong-basic pdftags pdfstandard(ua-1) --- +--- deco-tags-strong-basic pdftags --- +#set pdf(standard: "ua-1") This *HERE* is important! ---- deco-tags-strong-and-em pdftags pdfstandard(ua-1) --- +--- deco-tags-strong-and-em pdftags --- +#set pdf(standard: "ua-1") _*strong and emph*_ ---- deco-tags-strong-em-and-more-attrs pdftags pdfstandard(ua-1) --- +--- deco-tags-strong-em-and-more-attrs pdftags --- +#set pdf(standard: "ua-1") #underline(stroke: green)[_*strong and emph*_] diff --git a/tests/suite/pdftags/disable.typ b/tests/suite/pdftags/disable.typ index f66d35ae5687..fac9853fbb4b 100644 --- a/tests/suite/pdftags/disable.typ +++ b/tests/suite/pdftags/disable.typ @@ -1,4 +1,5 @@ ---- disable-tags-artifact pdftags pdfstandard(ua-1) --- +--- disable-tags-artifact pdftags --- +#set pdf(standard: "ua-1") = Heading 1 #pdf.artifact[ #table( @@ -10,7 +11,8 @@ = Heading 2 ---- disable-tags-tiling pdftags pdfstandard(ua-1) --- +--- disable-tags-tiling pdftags --- +#set pdf(standard: "ua-1") = Rectangle #let pat = tiling(size: (20pt, 20pt))[ @@ -20,7 +22,8 @@ ] #rect(fill: pat) ---- disable-tags-hide pdftags pdfstandard(ua-1) --- +--- disable-tags-hide pdftags --- +#set pdf(standard: "ua-1") = Hidden #hide[ @@ -29,7 +32,8 @@ - c ] ---- disable-tags-partially-hidden-list pdftags pdfstandard(ua-1) --- +--- disable-tags-partially-hidden-list pdftags --- +#set pdf(standard: "ua-1") // FIXME(accessibility): In realization, tags inside of list groupings aren't // handled. Thus if the head of the list is visible, all tags of list items // will be emitted before (outside) the hide element. And if the head is not @@ -48,22 +52,26 @@ - b - c ---- disable-tags-broken-paragraph-hide pdftags pdfstandard(ua-1) --- +--- disable-tags-broken-paragraph-hide pdftags --- +#set pdf(standard: "ua-1") This is the #hide[ first paragraph. And this is the ] second paragraph. ---- disable-tags-broken-paragraph-artifact pdftags pdfstandard(ua-1) --- +--- disable-tags-broken-paragraph-artifact pdftags --- +#set pdf(standard: "ua-1") This is the #pdf.artifact[ first paragraph. And this is the ] second paragraph. ---- background-artifact-1-7 pdf pdfstandard(1.7) --- +--- background-artifact-1-7 pdf --- +#set pdf(standard: "1.7") #set page(fill: eastern) #rect() #pdf.artifact(kind: "background", circle()) ---- background-artifact-2-0 pdf pdfstandard(2.0) --- +--- background-artifact-2-0 pdf --- +#set pdf(standard: "2.0") #set page(fill: eastern) #rect() #pdf.artifact(kind: "background", circle()) diff --git a/tests/suite/pdftags/figure.typ b/tests/suite/pdftags/figure.typ index 86158187315d..ba6200a8e1e8 100644 --- a/tests/suite/pdftags/figure.typ +++ b/tests/suite/pdftags/figure.typ @@ -1,8 +1,10 @@ ---- figure-tags-image-basic pdftags pdfstandard(ua-1) --- +--- figure-tags-image-basic pdftags --- +#set pdf(standard: "ua-1") // The image is automatically wrapped in a figure tag. #image(alt: "A tiger", "/assets/images/tiger.jpg") ---- figure-tags-image-figure-with-caption pdftags pdfstandard(ua-1) --- +--- figure-tags-image-figure-with-caption pdftags --- +#set pdf(standard: "ua-1") #figure( // The image doesn't create a duplicate figure tag // and the alt description is used in the outer figure. @@ -12,7 +14,8 @@ caption: [Some caption] ) ---- figure-tags-inline-equation-with-caption pdftags pdfstandard(ua-1) --- +--- figure-tags-inline-equation-with-caption pdftags --- +#set pdf(standard: "ua-1") #figure( math.equation( alt: "The Pythagorean theorem: a squared plus b squared is c squared", @@ -21,7 +24,8 @@ caption: [Some caption] ) ---- figure-tags-block-equation-with-caption pdftags pdfstandard(ua-1) --- +--- figure-tags-block-equation-with-caption pdftags --- +#set pdf(standard: "ua-1") #figure( // The alt description is used in the outer figure. math.equation( @@ -34,7 +38,8 @@ caption: [Some caption] ) ---- figure-tags-with-alt-flatten-content-basic pdftags pdfstandard(ua-1) --- +--- figure-tags-with-alt-flatten-content-basic pdftags --- +#set pdf(standard: "ua-1") // The inner tags are flattened #figure(alt: "alt text")[ $a^2 + b^2 = c^2$ @@ -42,7 +47,8 @@ $sum_(i=1)^n(i)$ ] ---- figure-tags-with-alt-flatten-content-complex pdftags pdfstandard(ua-1) --- +--- figure-tags-with-alt-flatten-content-complex pdftags --- +#set pdf(standard: "ua-1") #figure(alt: "alt text")[ #table( columns: 2, @@ -55,14 +61,16 @@ ) ] ---- figure-tags-alt-with-different-lang pdftags pdfstandard(ua-1) --- +--- figure-tags-alt-with-different-lang pdftags --- +#set pdf(standard: "ua-1") #set text(lang: "de") Ein Paragraph. #set text(lang: "en", region: "uk") #figure(image(alt: "A tiger", "/assets/images/tiger.jpg")) ---- figure-tags-listing pdftags pdfstandard(ua-1) --- +--- figure-tags-listing pdftags --- +#set pdf(standard: "ua-1") #figure[ ```rs fn main() { @@ -71,7 +79,8 @@ Ein Paragraph. ``` ] ---- figure-tags-only-marked-content-missing-alt pdftags pdfstandard(ua-1) --- +--- figure-tags-only-marked-content-missing-alt pdftags --- +#set pdf(standard: "ua-1") // Error: 1:2-3:2 PDF/UA-1 error: missing alt text // Hint: 1:2-3:2 make sure your images and equations have alt text #figure[ @@ -89,7 +98,8 @@ Ein Paragraph. #figure.caption[Additional caption] ] ---- figure-tags-additional-caption-inside-table pdftags pdfstandard(ua-1) --- +--- figure-tags-additional-caption-inside-table pdftags --- +#set pdf(standard: "ua-1") // Error: 19-35 PDF/UA-1 error: invalid table (Table) structure // Hint: 19-35 table (Table) may not contain multiple caption (Caption) tags // Hint: 19-35 avoid manually calling `figure.caption` @@ -105,7 +115,8 @@ Ein Paragraph. #figure.caption[Additional caption] ] ---- figure-tags-bbox-of-square-with-stroke pdftags pdfstandard(ua-1) --- +--- figure-tags-bbox-of-square-with-stroke pdftags --- +#set pdf(standard: "ua-1") #figure( alt: "A square with a red stroke", square(size: 60pt, stroke: 10pt + red) diff --git a/tests/suite/pdftags/footnote.typ b/tests/suite/pdftags/footnote.typ index c5be8f277bc9..a7c11a66a7f1 100644 --- a/tests/suite/pdftags/footnote.typ +++ b/tests/suite/pdftags/footnote.typ @@ -1,7 +1,9 @@ ---- footnote-tags-basic pdftags pdfstandard(ua-1) --- +--- footnote-tags-basic pdftags --- +#set pdf(standard: "ua-1") Footnote #footnote[Hi] in text. ---- footnote-tags-different-lang pdftags pdfstandard(ua-1) --- +--- footnote-tags-different-lang pdftags --- +#set pdf(standard: "ua-1") Footnote #footnote[ // The footnote number is still in English ("en"), so the link tag // holding the number should specify its language to be English, so @@ -10,5 +12,6 @@ Footnote #footnote[ Hallo ] in text. ---- footnote-tags-ref-to-other-footnote pdftags pdfstandard(ua-1) --- +--- footnote-tags-ref-to-other-footnote pdftags --- +#set pdf(standard: "ua-1") This #footnote[content] and #footnote(). diff --git a/tests/suite/pdftags/grid.typ b/tests/suite/pdftags/grid.typ index 917394df9199..013fdc843fa7 100644 --- a/tests/suite/pdftags/grid.typ +++ b/tests/suite/pdftags/grid.typ @@ -1,4 +1,5 @@ ---- grid-tags-rowspan pdftags pdfstandard(ua-1) --- +--- grid-tags-rowspan pdftags --- +#set pdf(standard: "ua-1") #grid( columns: 4, stroke: 1pt, @@ -10,7 +11,8 @@ [b], ) ---- grid-tags-cell-breaking pdftags pdfstandard(ua-1) --- +--- grid-tags-cell-breaking pdftags --- +#set pdf(standard: "ua-1") // The second paragraph contains marked content from page 1 and 2 #set page(width: 5cm, height: 3cm) #grid( @@ -26,7 +28,8 @@ [Rampage], ) ---- grid-tags-rowspan-split-3 pdftags pdfstandard(ua-1) --- +--- grid-tags-rowspan-split-3 pdftags --- +#set pdf(standard: "ua-1") #set page(height: 5em) #grid( columns: 2, @@ -37,7 +40,8 @@ [a], [b], ) ---- grid-tags-internal-grid-layout-breaking-bibliography pdftags pdfstandard(ua-1) --- +--- grid-tags-internal-grid-layout-breaking-bibliography pdftags --- +#set pdf(standard: "ua-1") #set page(height: 140pt) #cite(label("DBLP:books/lib/Knuth86a")) diff --git a/tests/suite/pdftags/heading.typ b/tests/suite/pdftags/heading.typ index b70a6223c6f5..c9febdea84ab 100644 --- a/tests/suite/pdftags/heading.typ +++ b/tests/suite/pdftags/heading.typ @@ -1,19 +1,23 @@ ---- heading-tags-basic pdftags pdfstandard(ua-1) --- +--- heading-tags-basic pdftags --- +#set pdf(standard: "ua-1") = Level 1 == Level 2 === Level 3 ---- heading-tags-first-is-not-level-1 pdftags pdfstandard(ua-1) --- +--- heading-tags-first-is-not-level-1 pdftags --- +#set pdf(standard: "ua-1") // Error: 1-11 PDF/UA-1 error: the first heading must be of level 1 == Level 2 ---- heading-tags-non-consecutive-levels pdftags pdfstandard(ua-1) --- +--- heading-tags-non-consecutive-levels pdftags --- +#set pdf(standard: "ua-1") = Level 1 // Error: 1-12 PDF/UA-1 error: skipped from heading level 1 to 3 // Hint: 1-12 heading levels must be consecutive === Level 3 ---- heading-tags-complex pdftags pdfstandard(ua-1) --- +--- heading-tags-complex pdftags --- +#set pdf(standard: "ua-1") = Level 1 == Level 2 === Level 3 @@ -28,11 +32,13 @@ = Level 1 == Level 2 ---- heading-tags-empty pdftags pdfstandard(ua-1) --- +--- heading-tags-empty pdftags --- +#set pdf(standard: "ua-1") // Error: 1-2 PDF/UA-1 error: heading title is empty = ---- heading-tags-context-body pdftags pdfstandard(ua-1) --- +--- heading-tags-context-body pdftags --- +#set pdf(standard: "ua-1") // Error: 2-32 PDF/UA-1 error: heading title could not be determined // Hint: 2-32 this seems to be caused by a context expression within the heading // Hint: 2-32 consider wrapping the entire heading in a context expression instead diff --git a/tests/suite/pdftags/lang.typ b/tests/suite/pdftags/lang.typ index d430b4e5a5a4..0aa4da7ded21 100644 --- a/tests/suite/pdftags/lang.typ +++ b/tests/suite/pdftags/lang.typ @@ -1,4 +1,5 @@ ---- lang-tags-pars-basic pdftags pdfstandard(ua-1) --- +--- lang-tags-pars-basic pdftags --- +#set pdf(standard: "ua-1") #set text(lang: "uk") Par 1. @@ -8,7 +9,8 @@ Par 2. #set text(lang: "be") Par 3. ---- lang-tags-propagation pdftags pdfstandard(ua-1) --- +--- lang-tags-propagation pdftags --- +#set pdf(standard: "ua-1") #set text(lang: "nl") A paragraph. diff --git a/tests/suite/pdftags/layout.typ b/tests/suite/pdftags/layout.typ index ec4968129216..ba4f9cd0bfe2 100644 --- a/tests/suite/pdftags/layout.typ +++ b/tests/suite/pdftags/layout.typ @@ -1,4 +1,5 @@ ---- layout-tags-placement-float pdftags pdfstandard(ua-1) --- +--- layout-tags-placement-float pdftags --- +#set pdf(standard: "ua-1") #set text(lang: "de") #grid( columns: 2, diff --git a/tests/suite/pdftags/link.typ b/tests/suite/pdftags/link.typ index 4a9a51a12702..03c6f48a5adf 100644 --- a/tests/suite/pdftags/link.typ +++ b/tests/suite/pdftags/link.typ @@ -1,35 +1,42 @@ ---- link-tags-heading-without-numbering pdftags pdfstandard(ua-1) --- +--- link-tags-heading-without-numbering pdftags --- +#set pdf(standard: "ua-1") = Heading #link()[link to heading] ---- link-tags-heading-with-numbering pdftags pdfstandard(ua-1) --- +--- link-tags-heading-with-numbering pdftags --- +#set pdf(standard: "ua-1") #set heading(numbering: "1.") = Heading #link()[link to heading] ---- link-tags-non-refable-location pdftags pdfstandard(ua-1) --- +--- link-tags-non-refable-location pdftags --- +#set pdf(standard: "ua-1") A random location #link()[link to somewhere] ---- link-tags-contact-prefix pdftags pdfstandard(ua-1) --- +--- link-tags-contact-prefix pdftags --- +#set pdf(standard: "ua-1") #link("mailto:hello@typst.app") #link("tel:123") ---- link-tags-position pdftags pdfstandard(ua-1) --- +--- link-tags-position pdftags --- +#set pdf(standard: "ua-1") #context link(here().position())[somewhere] ---- link-tags-link-in-artifact pdftags pdfstandard(ua-1) --- +--- link-tags-link-in-artifact pdftags --- +#set pdf(standard: "ua-1") #pdf.artifact[ // Error: 4-42 PDF/UA-1 error: PDF artifacts may not contain links // Hint: 4-42 references, citations, and footnotes are also considered links in PDF #link("https://github.com/typst/typst") ] ---- link-tags-reference-in-artifact pdftags pdfstandard(ua-1) --- +--- link-tags-reference-in-artifact pdftags --- +#set pdf(standard: "ua-1") #set heading(numbering: "1.") = Heading #pdf.artifact[ @@ -38,7 +45,8 @@ A random location @heading ] ---- link-tags-citation-in-artifact pdftags pdfstandard(ua-1) --- +--- link-tags-citation-in-artifact pdftags --- +#set pdf(standard: "ua-1") #pdf.artifact[ // Error: 3-10 PDF/UA-1 error: PDF artifacts may not contain links // Hint: 3-10 references, citations, and footnotes are also considered links in PDF @@ -47,7 +55,8 @@ A random location #show bibliography: none #bibliography("/assets/bib/works.bib") ---- link-tags-with-parbreak-error pdftags pdfstandard(ua-1) --- +--- link-tags-with-parbreak-error pdftags --- +#set pdf(standard: "ua-1") // Error: 7-69 PDF/UA-1 error: invalid document structure, this element's PDF tag would be split up // Hint: 7-69 this is probably caused by paragraph grouping // Hint: 7-69 maybe you've used a `parbreak`, `colbreak`, or `pagebreak` diff --git a/tests/suite/pdftags/list.typ b/tests/suite/pdftags/list.typ index a3d5265d85b0..34dd00161835 100644 --- a/tests/suite/pdftags/list.typ +++ b/tests/suite/pdftags/list.typ @@ -1,4 +1,5 @@ ---- list-tags-basic pdftags pdfstandard(ua-1) --- +--- list-tags-basic pdftags --- +#set pdf(standard: "ua-1") - a - 1 - b @@ -7,7 +8,8 @@ - e - f ---- list-tags-mixed-with-enum pdftags pdfstandard(ua-1) --- +--- list-tags-mixed-with-enum pdftags --- +#set pdf(standard: "ua-1") - a + 1 - b @@ -16,7 +18,8 @@ + e - f ---- list-tags-wide-with-nested-list pdftags pdfstandard(ua-1) --- +--- list-tags-wide-with-nested-list pdftags --- +#set pdf(standard: "ua-1") - a - 1 @@ -31,14 +34,16 @@ - f ---- list-tags-complex-item-with-sub-list pdftags pdfstandard(ua-1) --- +--- list-tags-complex-item-with-sub-list pdftags --- +#set pdf(standard: "ua-1") - #[#quote(block: true)[hi] #footnote[1].] - a - b - c - d ---- list-tags-complex-item-with-nested-list pdftags pdfstandard(ua-1) --- +--- list-tags-complex-item-with-nested-list pdftags --- +#set pdf(standard: "ua-1") - #[ #quote(block: true)[hi] #footnote[1]. @@ -48,29 +53,34 @@ - c - d ---- list-tags-terms-basic pdftags pdfstandard(ua-1) --- +--- list-tags-terms-basic pdftags --- +#set pdf(standard: "ua-1") / A: 1 / B: 2 / C: 3 ---- list-tags-terms-basic-wide pdftags pdfstandard(ua-1) --- +--- list-tags-terms-basic-wide pdftags --- +#set pdf(standard: "ua-1") / A: 1 / B: 2 / C: 3 ---- list-tags-terms-indented pdftags pdfstandard(ua-1) --- +--- list-tags-terms-indented pdftags --- +#set pdf(standard: "ua-1") / A: 1 / B: 2 / B1: wow / B2: amazing ---- list-tags-terms-body-with-parbreak pdftags pdfstandard(ua-1) --- +--- list-tags-terms-body-with-parbreak pdftags --- +#set pdf(standard: "ua-1") / A: 1 #parbreak() 232 / B: 2 ---- list-tags-terms-label-with-parbreak-error pdftags pdfstandard(ua-1) --- +--- list-tags-terms-label-with-parbreak-error pdftags --- +#set pdf(standard: "ua-1") // TODO: This should have the span of the term label, not the entire term item // Error: 1-21 PDF/UA-1 error: invalid document structure, this element's PDF tag would be split up // Hint: 1-21 this is probably caused by paragraph grouping diff --git a/tests/suite/pdftags/logical-children.typ b/tests/suite/pdftags/logical-children.typ index 3fab940c70bc..88737397f709 100644 --- a/tests/suite/pdftags/logical-children.typ +++ b/tests/suite/pdftags/logical-children.typ @@ -1,4 +1,5 @@ ---- logical-children-tags-place-within-artifact pdftags pdfstandard(ua-1) --- +--- logical-children-tags-place-within-artifact pdftags --- +#set pdf(standard: "ua-1") // Error: 2:4-4:4 PDF/UA-1 error: PDF artifacts may not contain links // Hint: 2:4-4:4 references, citations, and footnotes are also considered links in PDF #pdf.artifact[ @@ -7,7 +8,8 @@ ] ] ---- logical-children-tags-link-within-place-within-artifact pdftags pdfstandard(ua-1) --- +--- logical-children-tags-link-within-place-within-artifact pdftags --- +#set pdf(standard: "ua-1") // Error: 3:6-5:6 PDF/UA-1 error: PDF artifacts may not contain links // Hint: 3:6-5:6 references, citations, and footnotes are also considered links in PDF #pdf.artifact[ @@ -18,7 +20,8 @@ ] ] ---- logical-children-tags-footnote-in-tiling pdftags pdfstandard(ua-1) --- +--- logical-children-tags-footnote-in-tiling pdftags --- +#set pdf(standard: "ua-1") // Error: PDF/UA-1 error: PDF artifacts may not contain links // Hint: a link was used within a tiling // Hint: references, citations, and footnotes are also considered links in PDF @@ -26,12 +29,14 @@ #footnote[hi] ]) ---- logical-children-tags-place-in-tiling pdftags pdfstandard(ua-1) empty --- +--- logical-children-tags-place-in-tiling pdftags empty --- +#set pdf(standard: "ua-1") #rect(width: 90pt, height: 90pt, fill: tiling(size: (30pt, 30pt))[ #place(float: true, top + right)[hi] ]) ---- logical-children-tags-decorations-in-broken-grid-cell pdftags pdfstandard(ua-1) --- +--- logical-children-tags-decorations-in-broken-grid-cell pdftags --- +#set pdf(standard: "ua-1") #set page(height: 50pt) #grid( columns: 2, @@ -43,14 +48,16 @@ ], ) ---- logical-children-tags-hide-around-footnote pdftags pdfstandard(ua-1) --- +--- logical-children-tags-hide-around-footnote pdftags --- +#set pdf(standard: "ua-1") #hide[ Some text #footnote[explanation]. ] Some other text. ---- logical-children-tags-hide-around-place pdftags pdfstandard(ua-1) --- +--- logical-children-tags-hide-around-place pdftags --- +#set pdf(standard: "ua-1") #hide[ Some text #place(float: true, bottom + right)[explanation]. ] @@ -58,14 +65,16 @@ Some other text. Some other text. ---- logical-children-tags-underline-around-footnote pdftags pdfstandard(ua-1) --- +--- logical-children-tags-underline-around-footnote pdftags --- +#set pdf(standard: "ua-1") #underline[ Some text #footnote[explanation]. ] Some other text. ---- logical-children-tags-underline-around-place paged pdftags pdfstandard(ua-1) --- +--- logical-children-tags-underline-around-place paged pdftags --- +#set pdf(standard: "ua-1") #underline[ Some text #place(float: true, bottom + right)[explanation]. ] diff --git a/tests/suite/pdftags/query.typ b/tests/suite/pdftags/query.typ index 2fb0cbbe2fe3..33ee9c2ad15e 100644 --- a/tests/suite/pdftags/query.typ +++ b/tests/suite/pdftags/query.typ @@ -1,16 +1,19 @@ ---- query-tags-duplicate-heading pdftags pdfstandard(ua-1) --- +--- query-tags-duplicate-heading pdftags --- +#set pdf(standard: "ua-1") // This will display the heading with the same location a second time #context query(heading).join() = Hi ---- query-tags-duplicate-labelled-element pdftags pdfstandard(ua-1) --- +--- query-tags-duplicate-labelled-element pdftags --- +#set pdf(standard: "ua-1") #figure(alt: "Text saying: hello there")[ hello there ]

#context query(
).at(0) ---- query-tags-ambiguous-parent-place-error pdftags pdfstandard(ua-1) --- +--- query-tags-ambiguous-parent-place-error pdftags --- +#set pdf(standard: "ua-1") // Error: 2-43 PDF/UA-1 error: ambiguous logical parent // Hint: 2-43 please report this as a bug #place(float: true, top + left)[something] @@ -22,7 +25,8 @@ #context query().join() ---- query-tags-ambiguous-parent-footnote-error pdftags pdfstandard(ua-1) --- +--- query-tags-ambiguous-parent-footnote-error pdftags --- +#set pdf(standard: "ua-1") // Error: 2-21 PDF/UA-1 error: ambiguous logical parent // Hint: 2-21 please report this as a bug #footnote[something] diff --git a/tests/suite/pdftags/raw.typ b/tests/suite/pdftags/raw.typ index 7656cccc1381..13a75841fe6a 100644 --- a/tests/suite/pdftags/raw.typ +++ b/tests/suite/pdftags/raw.typ @@ -1,4 +1,5 @@ ---- raw-tags-basic-block pdftags pdfstandard(ua-1) --- +--- raw-tags-basic-block pdftags --- +#set pdf(standard: "ua-1") ```rs const PDF_STANDARD: &str = "PDF/UA-1"; @@ -7,5 +8,6 @@ fn main() { } ``` ---- raw-tags-basic-inline pdftags pdfstandard(ua-1) --- +--- raw-tags-basic-inline pdftags --- +#set pdf(standard: "ua-1") Some `inline raw` text and the rust ```rs fn``` keyword. diff --git a/tests/suite/pdftags/table.typ b/tests/suite/pdftags/table.typ index 2813f6eb56a3..19746b72c9e7 100644 --- a/tests/suite/pdftags/table.typ +++ b/tests/suite/pdftags/table.typ @@ -1,7 +1,9 @@ ---- table-tags-empty pdftags pdfstandard(ua-1) --- +--- table-tags-empty pdftags --- +#set pdf(standard: "ua-1") #table() ---- table-tags-basic pdftags pdfstandard(ua-1) --- +--- table-tags-basic pdftags --- +#set pdf(standard: "ua-1") #table( columns: 3, table.header([H1], [H2], [H3]), @@ -9,7 +11,8 @@ [b1], [b2], [b3], ) ---- table-tags-column-and-row-header pdftags pdfstandard(ua-1) --- +--- table-tags-column-and-row-header pdftags --- +#set pdf(standard: "ua-1") #table( columns: 3, table.header([H1], [H2], [H3]), @@ -17,7 +20,8 @@ pdf.header-cell(scope: "row")[12:30], [b2], [b3], ) ---- table-tags-missing-cells pdftags pdfstandard(ua-1) --- +--- table-tags-missing-cells pdftags --- +#set pdf(standard: "ua-1") #table( columns: 3, table.header(level: 1, [H1], [H1], [H1]), @@ -39,7 +43,8 @@ ), ) ---- table-tags-explicit-lines pdftags pdfstandard(ua-1) --- +--- table-tags-explicit-lines pdftags --- +#set pdf(standard: "ua-1") #table( columns: 2, [a], table.vline(stroke: green), [b], @@ -48,7 +53,8 @@ table.hline(stroke: blue), ) ---- table-tags-unset-bottom-line pdftags pdfstandard(ua-1) --- +--- table-tags-unset-bottom-line pdftags --- +#set pdf(standard: "ua-1") #table( columns: 2, [a], [b], @@ -56,7 +62,8 @@ table.hline(stroke: none), ) ---- table-tags-different-default-border pdftags pdfstandard(ua-1) --- +--- table-tags-different-default-border pdftags --- +#set pdf(standard: "ua-1") #table( columns: 2, stroke: red + 2pt, @@ -67,7 +74,8 @@ table.hline(stroke: black), ) ---- table-tags-show-rule-error pdftags pdfstandard(ua-1) --- +--- table-tags-show-rule-error pdftags --- +#set pdf(standard: "ua-1") #set table(columns: (10pt, auto)) // Error: 2-30 PDF/UA-1 error: invalid table (Table) structure // Hint: 2-30 table (Table) may not contain raw text (Code) @@ -80,7 +88,8 @@ #show table: it => it.columns #table[A][B][C][D] ---- table-tags-rowspan-split-1 pdftags pdfstandard(ua-1) --- +--- table-tags-rowspan-split-1 pdftags --- +#set pdf(standard: "ua-1") #set page(height: 6em) #table( rows: (4em, auto, 4em), @@ -89,7 +98,8 @@ [b], [c], [d], ) ---- table-tags-rowspan-split-2 pdftags pdfstandard(ua-1) --- +--- table-tags-rowspan-split-2 pdftags --- +#set pdf(standard: "ua-1") #set page(height: 6em) #table( rows: (4em, auto, 4em), @@ -99,7 +109,8 @@ [a3], [c3], ) ---- table-tags-citation-in-repeated-header pdftags pdfstandard(ua-1) --- +--- table-tags-citation-in-repeated-header pdftags --- +#set pdf(standard: "ua-1") #set page(height: 60pt) #table( // Error: 16-23 PDF/UA-1 error: PDF artifacts may not contain links @@ -112,18 +123,21 @@ #show bibliography: none #bibliography("/assets/bib/works.bib") ---- table-tags-private-table-summary eval pdfstandard(ua-1) --- +--- table-tags-private-table-summary eval --- +#set pdf(standard: "ua-1") #table( // Error: 3-18 unexpected argument: summary summary: "nope", [A], ) ---- table-tags-private-cell-kind eval pdfstandard(ua-1) --- +--- table-tags-private-cell-kind eval --- +#set pdf(standard: "ua-1") // Error: 13-25 unexpected argument: kind #table.cell(kind: "nope")[A] ---- table-tags-unstable-functions pdftags pdfstandard(ua-1) --- +--- table-tags-unstable-functions pdftags --- +#set pdf(standard: "ua-1") #pdf.table-summary( summary: "The table summary", table( diff --git a/tests/suite/pdftags/text.typ b/tests/suite/pdftags/text.typ index 81efb50fa4a7..10f83632ba67 100644 --- a/tests/suite/pdftags/text.typ +++ b/tests/suite/pdftags/text.typ @@ -1,2 +1,3 @@ ---- issue-7763-text-tags-span-order pdftags pdfstandard(ua-1) --- +--- issue-7763-text-tags-span-order pdftags --- +#set pdf(standard: "ua-1") #stack[A text *outside* of _a paragraph_] diff --git a/tests/suite/scripting/methods.typ b/tests/suite/scripting/methods.typ index 7919fa9a3ce3..99d55e085490 100644 --- a/tests/suite/scripting/methods.typ +++ b/tests/suite/scripting/methods.typ @@ -39,7 +39,7 @@ #test($module.item(#1, #2)$, $#3$) --- field-call-missing-field eval --- -// Error: 6-13 module `pdf` does not contain `attache` +// Error: 6-13 function `pdf` does not contain field `attache` #pdf.attache() --- math-field-call-missing-field eval --- diff --git a/tests/suite/text/lang.typ b/tests/suite/text/lang.typ index c93b5c7390c5..2590211cafaf 100644 --- a/tests/suite/text/lang.typ +++ b/tests/suite/text/lang.typ @@ -1,16 +1,19 @@ // Test setting the document language. ---- text-lang paged html pdftags pdfstandard(ua-1) --- +--- text-lang paged html pdftags --- +#set pdf(standard: "ua-1") // without any region #set text(font: "Noto Serif CJK TC", lang: "zh") #outline() ---- text-lang-unknown-region paged html pdftags pdfstandard(ua-1) --- +--- text-lang-unknown-region paged html pdftags --- +#set pdf(standard: "ua-1") // with unknown region configured #set text(font: "Noto Serif CJK TC", lang: "zh", region: "XX") #outline() ---- text-lang-region paged html pdftags pdfstandard(ua-1) --- +--- text-lang-region paged html pdftags --- +#set pdf(standard: "ua-1") // with region configured #set text(font: "Noto Serif CJK TC", lang: "zh", region: "TW") #outline() diff --git a/tests/suite/visualize/color.typ b/tests/suite/visualize/color.typ index 6d6fbef93c9f..eaf5ae61b4e2 100644 --- a/tests/suite/visualize/color.typ +++ b/tests/suite/visualize/color.typ @@ -430,7 +430,8 @@ // Error: 30-37 expected color, found spot colorant #color.spot("PANTONE 185 C", pantone) ---- spot-colorant-inconsistent-fallback pdf pdfstandard(a-2a) --- +--- spot-colorant-inconsistent-fallback pdf --- +#set pdf(standard: "a-2a") // Error: spot colorant `"PANTONE 185 C"` appeared with multiple distinct fallback colors // Hint: define a particular spot colorant once and store it in a variable to reuse it later #set document(date: datetime(year: 1970, month: 1, day: 1)) From 7aef8290c4d28ee5090b8af796e8b39766ffde56 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 1 Sep 2026 15:17:38 +0000 Subject: [PATCH 084/105] Fix definition info sizing in PDF docs (#8805) --- docs/components/base.typ | 35 ---------------------- docs/components/category.typ | 58 +++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 45 deletions(-) diff --git a/docs/components/base.typ b/docs/components/base.typ index a723ace7b656..c1252dac94a6 100644 --- a/docs/components/base.typ +++ b/docs/components/base.typ @@ -222,41 +222,6 @@ .join() } -// Displays binding information, if any, such as: -// - A deprecation -// - A feature gate -#let definition-info(info) = { - if info == none { return } - - let item(size, name, alt, body) = { - context if target() == "paged" { - small(icon(size, name, alt) + [ ] + body) - } else { - html.small(class: "definition-info", { - html.div(use-icon(size, name, alt)) - html.span(body) - }) - } - } - - if info.feature != none { - item(16, "toggle", "Feature toggle", { - [Requires the ] - raw(info.feature) - [ feature] - }) - } - - if info.deprecation != none { - item(16, "warn", "Warning", { - text-with-code(info.deprecation.message) - if info.deprecation.until != none { - [; it will be removed in Typst #info.deprecation.until] - } - }) - } -} - // Displays a search box. // // Only support in the web output. diff --git a/docs/components/category.typ b/docs/components/category.typ index d6cb7b1f0672..61df84c06985 100644 --- a/docs/components/category.typ +++ b/docs/components/category.typ @@ -1,8 +1,8 @@ #import "system.typ": colors #import "base.typ": ( - classnames, definition-info, folding-details, heading-offset, html-heading-n, - labelled, oneliner, paged-heading-offset, short-or-long, small, title-case, - to-func, use-icon, with-tooltip, + classnames, folding-details, heading-offset, html-heading-n, icon, labelled, + oneliner, paged-heading-offset, short-or-long, small, text-with-code, + title-case, to-func, use-icon, with-tooltip, ) #import "example.typ": example, example-like-block #import "linking.typ": def-dest, def-label, register-def @@ -296,49 +296,87 @@ } } +// Displays binding information, if any, such as: +// - A deprecation +// - A feature gate +#let definition-info(info) = { + let gap = if target() == "paged" { h(0.5em, weak: true) } + + let item(size, name, alt, body) = { + context if target() == "paged" { + small(icon(size, name, alt) + [ ] + body) + } else { + html.small(class: "definition-info", { + html.div(use-icon(size, name, alt)) + html.span(body) + }) + } + } + + if info.feature != none { + gap + item(16, "toggle", "Feature toggle", { + [Requires the ] + raw(info.feature) + [ feature] + }) + } + + if info.deprecation != none { + gap + item(16, "warn", "Warning", { + text-with-code(info.deprecation.message) + if info.deprecation.until != none { + [; it will be removed in Typst #info.deprecation.until] + } + }) + } +} + // Displays additional details about a function. // // When the labels are changed here, `docs/content/reference/index.typ` needs to // change, too. #let func-subtitle(info, binding-info) = context { let gap = if target() == "paged" { h(0.5em, weak: true) } + set text(0.75em) if info.element { - set text(0.75em) gap small(with-tooltip[Element][ Element functions can be customized with `set` and `show` rules. ]) } if info.contextual != none and info.contextual { - set text(0.75em) gap small(with-tooltip[Contextual][ Contextual functions can only be used when the context is known. ]) } if info.since != none { - set text(0.75em) gap small[Since: #info.since] } else { panic("missing `since` for function " + info.def-site.key + " (" + stdx.str-from-path(info.def-site.path) + ")") } - gap - definition-info(binding-info) + if binding-info != none { + definition-info(binding-info) + } sources-link(info) } // Displays additional details about a type. #let ty-subtitle(ty-info, binding-info) = context { let gap = if target() == "paged" { h(0.5em, weak: true) } + set text(0.75em) if ty-info.since != none { - set text(0.75em) gap small[Since: #ty-info.since] } else { panic("missing `since` for type " + ty-info.def-site.key + " (" + stdx.str-from-path(ty-info.def-site.path) + ")") } - definition-info(binding-info) + if binding-info != none { + definition-info(binding-info) + } sources-link(ty-info) } From 48bd0295316a5c482025ec493ea7925df19de4bd Mon Sep 17 00:00:00 2001 From: cui fliter Date: Thu, 3 Sep 2026 17:06:20 +0000 Subject: [PATCH 085/105] Fix duration formatting at exact unit boundaries (#8812) Signed-off-by: cuishuang --- crates/typst-utils/src/duration.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/typst-utils/src/duration.rs b/crates/typst-utils/src/duration.rs index 19b647a47bdf..f7613c2d3cb0 100644 --- a/crates/typst-utils/src/duration.rs +++ b/crates/typst-utils/src/duration.rs @@ -49,16 +49,16 @@ impl Display for DurationDisplay { let nanos = secs * order(3) + self.0.subsec_nanos() as u64; let fract = |exp| round_with_precision(nanos as f64 / order(exp) as f64, 2); - if nanos == 0 || self.0 > Duration::from_secs(1) { - // For durations > 5 min, we drop the fractional part. - if self.0 > Duration::from_secs(300) { + if nanos == 0 || self.0 >= Duration::from_secs(1) { + // For durations >= 5 min, we drop the fractional part. + if self.0 >= Duration::from_mins(5) { piece!("{secs} s"); } else { piece!("{} s", fract(3)); } - } else if self.0 > Duration::from_millis(1) { + } else if self.0 >= Duration::from_millis(1) { piece!("{} ms", fract(2)); - } else if self.0 > Duration::from_micros(1) { + } else if self.0 >= Duration::from_micros(1) { piece!("{} µs", fract(1)); } else { piece!("{} ns", fract(0)); @@ -87,6 +87,12 @@ mod tests { test(Duration::from_secs_f64(264.776), "4 min 24.78 s"); test(Duration::from_secs(3), "3 s"); test(Duration::from_secs_f64(2.8492), "2.85 s"); + test(Duration::from_millis(999), "999 ms"); + test(Duration::from_secs(1), "1 s"); + test(Duration::from_micros(999), "999 µs"); + test(Duration::from_millis(1), "1 ms"); + test(Duration::from_nanos(999), "999 ns"); + test(Duration::from_micros(1), "1 µs"); test(Duration::from_micros(734), "734 µs"); test(Duration::from_micros(294816), "294.82 ms"); test(Duration::from_nanos(1), "1 ns"); From 47f1d1f93a43a85147f87e7df301284cb2dff192 Mon Sep 17 00:00:00 2001 From: Alessio Faieta <29785188+alessiofaieta@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:06:55 +0000 Subject: [PATCH 086/105] Fix typo in math fonts documentation (#8813) --- docs/content/reference/library/math.typ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/reference/library/math.typ b/docs/content/reference/library/math.typ index 2a13578927a1..0c242de94ef7 100644 --- a/docs/content/reference/library/math.typ +++ b/docs/content/reference/library/math.typ @@ -189,7 +189,7 @@ $ (3x + y) / 7 &= 9 && "given" \ ``` = Math fonts -You can set the math font by with a @reference:styling:show-rules[show-set rule] as demonstrated below. Note that only special OpenType math fonts are suitable for typesetting maths. +You can set the math font with a @reference:styling:show-rules[show-set rule] as demonstrated below. Note that only special OpenType math fonts are suitable for typesetting maths. ```example #show math.equation: set text(font: "Pennstander Math") From e060fa86afcb7a7d835b9fe437ccb2663b8a2dbc Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:26:44 +0000 Subject: [PATCH 087/105] Error in case of unsupported use of `stringify!` (#8825) Co-authored-by: Laurenz --- crates/typst-macros/src/cast.rs | 12 +++++----- crates/typst-macros/src/elem.rs | 4 ++-- crates/typst-macros/src/func.rs | 6 ++--- crates/typst-macros/src/ty.rs | 2 +- crates/typst-macros/src/util.rs | 40 ++++++++++++++++++++++----------- 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/crates/typst-macros/src/cast.rs b/crates/typst-macros/src/cast.rs index 35c042bf06db..55ef20a3b04c 100644 --- a/crates/typst-macros/src/cast.rs +++ b/crates/typst-macros/src/cast.rs @@ -32,7 +32,7 @@ pub fn derive_cast(item: DeriveInput) -> Result { variants.push(Variant { ident: variant.ident.clone(), string, - docs: documentation(&variant.attrs), + docs: documentation(&variant.attrs)?, }); } @@ -72,7 +72,7 @@ pub fn cast(stream: TokenStream) -> Result { let input: CastInput = syn::parse2(stream)?; let ty = &input.ty; let castable_body = create_castable_body(&input); - let input_body = create_input_body(&input); + let input_body = create_input_body(&input)?; let output_body = create_output_body(&input); let into_value_body = create_into_value_body(&input); let from_value_body = create_from_value_body(&input); @@ -239,11 +239,11 @@ fn create_castable_body(input: &CastInput) -> TokenStream { } } -fn create_input_body(input: &CastInput) -> TokenStream { +fn create_input_body(input: &CastInput) -> Result { let mut infos = vec![]; for cast in &input.from_value { - let docs = documentation(&cast.attrs); + let docs = documentation(&cast.attrs)?; infos.push(match &cast.pattern { Pattern::Str(lit) => { quote! { @@ -265,9 +265,9 @@ fn create_input_body(input: &CastInput) -> TokenStream { }); } - quote! { + Ok(quote! { #(#infos)+* - } + }) } fn create_output_body(input: &CastInput) -> TokenStream { diff --git a/crates/typst-macros/src/elem.rs b/crates/typst-macros/src/elem.rs index d7840c009240..d55162f048eb 100644 --- a/crates/typst-macros/src/elem.rs +++ b/crates/typst-macros/src/elem.rs @@ -161,7 +161,7 @@ fn parse(stream: TokenStream, body: &syn::ItemStruct) -> Result { Some(|base| base.trim_end_matches("Elem")), )?; - let docs = documentation(&body.attrs); + let docs = documentation(&body.attrs)?; let syn::Fields::Named(named) = &body.fields else { bail!(body, "expected named fields"); @@ -217,7 +217,7 @@ fn parse_field(field: &syn::Field) -> Result { vis: field.vis.clone(), ty: field.ty.clone(), name: ident.to_string().to_kebab_case(), - docs: documentation(&attrs), + docs: documentation(&attrs)?, positional, required, variadic, diff --git a/crates/typst-macros/src/func.rs b/crates/typst-macros/src/func.rs index e152ff0c9ec1..37633144ffdc 100644 --- a/crates/typst-macros/src/func.rs +++ b/crates/typst-macros/src/func.rs @@ -141,7 +141,7 @@ fn parse(stream: TokenStream, item: &syn::ItemFn) -> Result { let (name, title) = determine_name_and_title(meta.name, meta.title, &item.sig.ident, None)?; - let docs = documentation(&item.attrs); + let docs = documentation(&item.attrs)?; let mut special = SpecialParams::default(); let mut params = vec![]; @@ -202,7 +202,7 @@ fn parse_param( None => bail!(recv, "explicit parent type required"), }, name: "self".into(), - docs: documentation(&recv.attrs), + docs: documentation(&recv.attrs)?, named: false, variadic: false, external: false, @@ -231,7 +231,7 @@ fn parse_param( ident: ident.clone(), ty: (*typed.ty).clone(), name: ident.to_string().to_kebab_case(), - docs: documentation(&attrs), + docs: documentation(&attrs)?, named: has_attr(&mut attrs, "named"), variadic: has_attr(&mut attrs, "variadic"), external: has_attr(&mut attrs, "external"), diff --git a/crates/typst-macros/src/ty.rs b/crates/typst-macros/src/ty.rs index d1a648fda296..624a47c83fc8 100644 --- a/crates/typst-macros/src/ty.rs +++ b/crates/typst-macros/src/ty.rs @@ -68,7 +68,7 @@ impl Parse for Meta { /// Parse details about the type from its definition. fn parse(meta: Meta, ident: Ident, attrs: &[Attribute]) -> Result { - let docs = documentation(attrs); + let docs = documentation(attrs)?; let (name, title) = determine_name_and_title(meta.name.clone(), meta.title.clone(), &ident, None)?; let long = title.to_lowercase(); diff --git a/crates/typst-macros/src/util.rs b/crates/typst-macros/src/util.rs index a8f0871146a7..30a78c683b16 100644 --- a/crates/typst-macros/src/util.rs +++ b/crates/typst-macros/src/util.rs @@ -3,8 +3,9 @@ use proc_macro2::TokenStream; use quote::{ToTokens, quote}; use std::str::FromStr; use syn::parse::{Parse, ParseStream}; +use syn::spanned::Spanned; use syn::token::Token; -use syn::{Attribute, Ident, Result, Token}; +use syn::{Attribute, Error, Ident, Result, Token}; /// Return an error at the given item. macro_rules! bail { @@ -22,8 +23,22 @@ macro_rules! bail { }; } +/// A basic reimplementation of the `stringify!` macro. +/// +/// The `stringify!` macro does not expand eagerly so we have some very basic +/// support for int and float expressions here. This is e.g. used for paper +/// sizes. +fn stringify(tokens: TokenStream) -> Option { + let lit = syn::parse2::(tokens).ok()?; + match lit { + syn::Lit::Int(int) => Some(int.base10_digits().to_owned()), + syn::Lit::Float(float) => Some(float.base10_digits().to_owned()), + _ => None, + } +} + /// Extract documentation comments from an attribute list. -pub fn documentation(attrs: &[syn::Attribute]) -> String { +pub fn documentation(attrs: &[syn::Attribute]) -> Result { let mut doc = String::new(); // Parse doc comments. @@ -39,23 +54,22 @@ pub fn documentation(attrs: &[syn::Attribute]) -> String { doc.push_str(line); doc.push('\n'); } else if let syn::Expr::Macro(expr) = &meta.value - // The `stringify!` macro does not expand eagerly so we have - // some very basic support for int and float expressions here. - // This is e.g. used for paper sizes. && expr.mac.path.is_ident("stringify") - && let Ok(lit) = syn::parse2::(expr.mac.tokens.clone()) - && let Some(value) = match &lit { - syn::Lit::Int(int) => Some(int.base10_digits()), - syn::Lit::Float(float) => Some(float.base10_digits()), - _ => None, - } { - doc.push_str(value); + { + let value = stringify(expr.mac.tokens.clone()).ok_or_else(|| { + Error::new( + expr.span(), + "the `stringify!` macro is not fully supported in \ + the Typst documentation", + ) + })?; + doc.push_str(&value); doc.push('\n'); } } } - doc.trim().into() + Ok(doc.trim().into()) } /// Whether an attribute list has a specified attribute. From 4232cd8f35d98351576d9ce3046095e09b60e760 Mon Sep 17 00:00:00 2001 From: Igor Khanin Date: Mon, 7 Sep 2026 13:53:59 +0000 Subject: [PATCH 088/105] Make `IdeWorld::files` more flexible (#8699) Co-authored-by: Laurenz --- crates/typst-ide/src/complete.rs | 48 ++++++++++++++++++++++++++------ crates/typst-ide/src/lib.rs | 13 +++++++-- crates/typst-ide/src/tests.rs | 12 +++++++- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 4a4c464b2e99..ffbd7266a751 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -12,7 +12,7 @@ use typst::foundations::{ use typst::layout::{Alignment, Dir}; use typst::syntax::ast::AstNode; use typst::syntax::{ - FileId, LinkedNode, Side, Source, SyntaxKind, SyntaxMode, ast, is_id_continue, + LinkedNode, Side, Source, SyntaxKind, SyntaxMode, VirtualPath, ast, is_id_continue, is_id_start, is_ident, }; use typst::text::{FontFlags, RawElem}; @@ -1161,16 +1161,19 @@ impl<'a> CompletionContext<'a> { } /// Add completions for all available files. - fn file_completions(&mut self, mut filter: impl FnMut(FileId) -> bool) { + fn file_completions(&mut self, mut filter: impl FnMut(&VirtualPath) -> bool) { let Some(current_id) = self.leaf.span().id() else { return }; let Some(current_dir) = current_id.vpath().parent() else { return }; + let prefix = + Some(self.before[self.from..].trim_matches('\"')).filter(|s| !s.is_empty()); + let mut paths: Vec = self .world - .files() + .files(current_id, prefix) .iter() - .filter(|&&id| id != current_id && filter(id)) - .map(|id| id.vpath().relative_from(¤t_dir)) + .filter(|&path| path != current_id.vpath() && filter(path)) + .map(|path| path.relative_from(¤t_dir)) .collect(); paths.sort(); @@ -1187,9 +1190,8 @@ impl<'a> CompletionContext<'a> { if extensions.is_empty() { self.file_completions(|_| true); } - self.file_completions(|id| { - let ext = id - .vpath() + self.file_completions(|path| { + let ext = path .extension() .map(EcoString::from) .unwrap_or_default() @@ -1919,6 +1921,36 @@ mod tests { .must_exclude([q!("f.typ")]); } + #[test] + fn test_autocomplete_file_path_with_prefix() { + let world = TestWorld::new("") + .with_source("content/main.typ", "#read(\"a\")") + .with_source("content/test1.typ", "#read(\"\")") + .with_source("content/test2.typ", "#read(\"..\")") + .with_source("content/test3.typ", "#read(\"../assets/r\")") + .with_source("content/aux.typ", "") + .with_asset_at("assets/tiger.jpg", "tiger.jpg") + .with_asset_at("assets/rhino.png", "rhino.png"); + + test(&world, ("content/main.typ", -3)) + .must_include([q!("aux.typ")]) + .must_exclude([q!("test.typ")]); + + test(&world, ("content/test1.typ", -3)).must_include([ + q!("aux.typ"), + q!("../assets/tiger.jpg"), + q!("../assets/tiger.jpg"), + ]); + + test(&world, ("content/test2.typ", -3)) + .must_include([q!("../assets/rhino.png"), q!("../assets/tiger.jpg")]) + .must_exclude([q!("aux.typ")]); + + test(&world, ("content/test3.typ", -3)) + .must_include([q!("../assets/rhino.png")]) + .must_exclude([q!("aux.typ"), q!("../assets/tiger.jpg")]); + } + #[test] fn test_autocomplete_figure_snippets() { let res = test("#figure()", -2); diff --git a/crates/typst-ide/src/lib.rs b/crates/typst-ide/src/lib.rs index 174a4baba668..25ba2824900b 100644 --- a/crates/typst-ide/src/lib.rs +++ b/crates/typst-ide/src/lib.rs @@ -18,8 +18,8 @@ pub use self::tooltip::{Tooltip, tooltip}; use ecow::EcoString; use typst::World; -use typst::syntax::FileId; use typst::syntax::package::PackageSpec; +use typst::syntax::{FileId, VirtualPath}; /// Extends the `World` for IDE functionality. pub trait IdeWorld: World { @@ -41,11 +41,18 @@ pub trait IdeWorld: World { &[] } - /// Returns a list of all known files. + /// Returns a list of relevant path completion candidates. + /// + /// The `base` parameter refers to the source file where the completion is + /// being performed, and `prefix` contains an already entered path to be + /// extended (if any). Implementations may use either piece of information + /// at their discretion to narrow down the list of candidates, i.e. for + /// relevance or performance. /// /// This function is **optional** to implement. It enhances the user /// experience by enabling autocompletion for file paths. - fn files(&self) -> Vec { + #[allow(unused_variables)] + fn files(&self, base: FileId, prefix: Option<&str>) -> Vec { vec![] } } diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index a2fe48861b7d..963b71419379 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -117,10 +117,20 @@ impl IdeWorld for TestWorld { self } - fn files(&self) -> Vec { + fn files(&self, base: FileId, prefix: Option<&str>) -> Vec { + let dir = base.vpath().parent(); + std::iter::once(self.main.id()) .chain(self.files.sources.keys().copied()) .chain(self.files.assets.keys().copied()) + .map(|id| id.vpath().clone()) + .filter(|path| { + prefix.is_none_or(|prefix| { + dir.as_ref() + .map(|dir| path.relative_from(dir)) + .is_none_or(|relative| relative.starts_with(prefix)) + }) + }) .collect() } From 26e924d311367d69e819a62bfd505ed6787b4537 Mon Sep 17 00:00:00 2001 From: Josh Triplett Date: Mon, 7 Sep 2026 14:10:26 +0000 Subject: [PATCH 089/105] Add `-i` shorthand for `--input` (#8736) Co-authored-by: Laurenz --- crates/typst-cli/src/args.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index c84c47264518..6104efb5ce72 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -435,6 +435,7 @@ pub struct WorldArgs { /// Add a string key-value pair visible through `sys.inputs`. #[clap( + short = 'i', long = "input", value_name = "key=value", action = ArgAction::Append, From d4cca3c1c43b7b449cf105e21f46710bfce7cb67 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Wed, 9 Sep 2026 15:17:47 +0000 Subject: [PATCH 090/105] Bump a few dependencies with advisories (#8847) --- Cargo.lock | 38 +++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13509f0cd006..a4281cfdd037 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,9 +553,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] @@ -601,12 +601,9 @@ checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" -dependencies = [ - "powerfmt", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "dirs" @@ -1706,9 +1703,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.5" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -1821,9 +1818,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -2256,9 +2253,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.38.3" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", @@ -2962,30 +2959,29 @@ dependencies = [ [[package]] name = "time" -version = "0.3.37" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.19" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2834e6017e3e5e4b9834939793b282bc03b37a3336245fa820e35e233e2a85de" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", diff --git a/Cargo.toml b/Cargo.toml index 6f1d824822bc..c224642b28e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,7 +127,7 @@ syntect = { version = "5.3", default-features = false, features = ["parsing", "r tar = "0.4.46" tempfile = "3.7.0" thin-vec = "0.2.18" -time = { version = "0.3.20", features = ["formatting", "macros", "parsing"] } +time = { version = "0.3.55", features = ["formatting", "macros", "parsing"] } tiny_http = "0.12" tiny-skia = "0.12" toml = { version = "0.8", default-features = false, features = ["parse", "display"] } From a73e705a5f3013876e4e38c52758a6706cc03819 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:07:34 +0000 Subject: [PATCH 091/105] Add keywords for `math.frak` (#8837) --- crates/typst-library/src/math/style.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/math/style.rs b/crates/typst-library/src/math/style.rs index 4e3b39d929d8..1566e43a0344 100644 --- a/crates/typst-library/src/math/style.rs +++ b/crates/typst-library/src/math/style.rs @@ -123,7 +123,11 @@ pub fn scr( /// ```example /// $ frak(P) $ /// ``` -#[func(title = "Fraktur", since = "forever", keywords = ["mathfrak"])] +#[func( + title = "Fraktur", + since = "forever", + keywords = ["mathfrak", "blackletter", "gothic"], +)] pub fn frak( /// The content to style. body: Content, From 586e1bd43fae6c9a973218163d3165c53ab8d16d Mon Sep 17 00:00:00 2001 From: Max Kortge Date: Sat, 12 Sep 2026 12:18:44 +0000 Subject: [PATCH 092/105] Improve type safety in flow layout (#8761) Co-authored-by: Laurenz --- crates/typst-layout/src/flow/compose.rs | 258 ++++++++++++++++----- crates/typst-layout/src/flow/distribute.rs | 125 +++++++--- crates/typst-layout/src/flow/mod.rs | 32 +-- 3 files changed, 286 insertions(+), 129 deletions(-) diff --git a/crates/typst-layout/src/flow/compose.rs b/crates/typst-layout/src/flow/compose.rs index c912879032a3..a89079f2a3a8 100644 --- a/crates/typst-layout/src/flow/compose.rs +++ b/crates/typst-layout/src/flow/compose.rs @@ -1,6 +1,8 @@ +use std::convert::Infallible; use std::num::NonZeroUsize; -use typst_library::diag::SourceResult; +use ecow::EcoVec; +use typst_library::diag::{SourceDiagnostic, SourceResult}; use typst_library::engine::Engine; use typst_library::foundations::{Content, NativeElement, Packed, Resolve, Smart}; use typst_library::introspection::{ @@ -18,17 +20,130 @@ use typst_library::model::{ use typst_syntax::Span; use typst_utils::{NonZeroExt, Numeric}; -use super::{ - Config, FlowMode, FlowResult, LineNumberConfig, PlacedChild, Stop, Work, distribute, -}; +use super::distribute::distribute; +use super::{Config, FlowMode, LineNumberConfig, PlacedChild, Work}; + +/// A control flow event during layout. +/// +/// The type parameter `R` describes the requested relayout. +pub(super) enum RelayoutStop { + /// Indicates that the given scope should be relayouted. + Relayout(R), + /// A fatal error. + Error(EcoVec), +} + +impl From> for RelayoutStop { + fn from(error: EcoVec) -> Self { + Self::Error(error) + } +} + +/// A version of [`PlacementScope`] that is known to be `Parent`. +pub struct ParentScope; + +impl From for PlacementScope { + fn from(_: ParentScope) -> Self { + PlacementScope::Parent + } +} + +/// A version of [`PlacementScope`] that is known to be `Column`. +pub struct ColumnScope; + +impl From for PlacementScope { + fn from(_: ColumnScope) -> Self { + PlacementScope::Column + } +} + +/// A control flow event during footnote handling. +pub(super) type FootnoteStop = InsertionStop; + +/// A control flow event during float handling. +pub(super) type FloatStop = InsertionStop; + +/// A control flow event while handling an insertion. +/// +/// The type parameter `R` describes the requested relayout. The type parameter +/// `M` controls the [`MigrateOrigin`](Self::MigrateOrigin) variant. When +/// `M = Infallible`, the variant is empty. +pub(super) enum InsertionStop { + /// The insertion changed the available space. + Relayout(R), + /// The footnote did not fit with its origin frame. + MigrateOrigin(M), + /// A fatal error. + Error(EcoVec), +} + +impl From> for FloatStop { + fn from(stop: FootnoteStop) -> Self { + match stop { + FootnoteStop::Relayout(ColumnScope) => Self::Relayout(PlacementScope::Column), + FootnoteStop::MigrateOrigin(migration) => Self::MigrateOrigin(migration), + FootnoteStop::Error(error) => Self::Error(error), + } + } +} + +impl From> for RelayoutStop +where + R: Into, +{ + fn from(stop: InsertionStop) -> Self { + match stop { + InsertionStop::Relayout(r) => Self::Relayout(r.into()), + InsertionStop::MigrateOrigin(never) => match never {}, + InsertionStop::Error(error) => Self::Error(error), + } + } +} + +impl From> for InsertionStop { + fn from(error: EcoVec) -> Self { + Self::Error(error) + } +} + +/// Whether a footnote's origin frame may migrate to the next region. +#[derive(Clone, Copy)] +pub(super) struct Migration(Option); + +impl Migration<()> { + /// Allow the origin frame to migrate. + pub(super) const ALLOW: Self = Self(Some(())); +} + +impl Migration { + /// Forbid the origin frame from migrating. + const FORBID: Self = Self(None); +} + +impl Migration { + /// Forbid the origin frame from migrating. + fn forbid(&mut self) { + self.0 = None; + } + + /// Disable migration and return the previous setting. + fn take(&mut self) -> Self { + Self(self.0.take()) + } + + /// Extract the inner value, if migration is allowed. + fn into_inner(self) -> Option { + self.0 + } +} /// Composes the contents of a single page/region. A region can have multiple /// columns/subregions. /// /// The composer is primarily concerned with layout of out-of-flow insertions -/// (floats and footnotes). It does this in per-page and per-column loops that -/// rerun when a new float is added (since it affects the regions available to -/// the distributor). +/// (floats and footnotes). It does this in per-page and per-column loops that +/// rerun when an insertion affects the regions available to the distributor, +/// or when balancing columns. /// /// To lay out the in-flow contents of individual subregions, the composer /// invokes [distribution](distribute()). @@ -83,7 +198,8 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { /// Lay out a container/page region, including container/page insertions. fn page(mut self, locator: Locator, regions: Regions) -> SourceResult { // This loop can restart region layout when requested to do so by a - // `Stop`. This happens when there is a parent-scoped float. + // `RelayoutStop::Relayout(ParentScope)`. This happens when there is a + // parent-scoped float or when balancing columns. let checkpoint = self.work.clone(); let output = loop { // Shrink the available space by the space used by page @@ -93,12 +209,10 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { match self.page_contents(locator.relayout(), pod) { Ok(frame) => break frame, - Err(Stop::Finish(_)) => unreachable!(), - Err(Stop::Relayout(PlacementScope::Column)) => unreachable!(), - Err(Stop::Relayout(PlacementScope::Parent)) => { + Err(RelayoutStop::Relayout(ParentScope)) => { *self.work = checkpoint.clone(); } - Err(Stop::Error(err)) => return Err(err), + Err(RelayoutStop::Error(err)) => return Err(err), } }; drop(checkpoint); @@ -107,7 +221,11 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { } /// Lay out the inner contents of a container/page. - fn page_contents(&mut self, locator: Locator, regions: Regions) -> FlowResult { + fn page_contents( + &mut self, + locator: Locator, + regions: Regions, + ) -> Result> { // No point in create column regions, if there's just one! if self.config.columns.count == 1 { return self.column(locator, regions).map(|(frame, ..)| frame); @@ -206,7 +324,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { let height = total_used_height / self.config.columns.count as f64; if self.column_balancing_height.is_none_or(|h| h < height) { self.column_balancing_height = Some(height); - return Err(Stop::Relayout(PlacementScope::Parent)); + return Err(RelayoutStop::Relayout(ParentScope)); } } @@ -215,7 +333,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { /// Lay out a column, including column insertions. /// - /// Returns a `FlowResult` containing a tuple of + /// On success, returns a tuple of /// - `0`: The laid out frame. /// - `1`: The height actually used by the inner contents (used for column /// balancing logic). @@ -224,7 +342,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { &mut self, locator: Locator, regions: Regions, - ) -> FlowResult<(Frame, Abs, Abs)> { + ) -> Result<(Frame, Abs, Abs), RelayoutStop> { // Reset column insertion when starting a new column. self.column_insertions = Insertions::default(); @@ -234,7 +352,8 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { } // This loop can restart column layout when requested to do so by a - // `Stop`. This happens when there is a column-scoped float. + // `RelayoutStop::Relayout(PlacementScope::Column)`. This happens when a + // column-scoped float or footnote is added. let checkpoint = self.work.clone(); let (inner, used_height) = loop { // Shrink the available space by the space used by column @@ -252,11 +371,15 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { match self.column_contents(pod, balancing_target) { Ok((frame, used_height)) => break (frame, used_height + float_height), - Err(Stop::Finish(_)) => unreachable!(), - Err(Stop::Relayout(PlacementScope::Column)) => { + Err(RelayoutStop::Relayout(PlacementScope::Column)) => { *self.work = checkpoint.clone(); } - Err(err) => return Err(err), + Err(RelayoutStop::Relayout(PlacementScope::Parent)) => { + return Err(RelayoutStop::Relayout(ParentScope)); + } + Err(RelayoutStop::Error(error)) => { + return Err(RelayoutStop::Error(error)); + } } }; drop(checkpoint); @@ -302,21 +425,21 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { /// however, we forbid footnote migration (moving the frame containing the /// footnote reference if the corresponding entry doesn't fit), allowing /// the footnote invariant to be broken, as it would require handling a - /// [`Stop::Finish`] at this point, but that is exclusively handled by the - /// distributor. + /// [`FootnoteStop::MigrateOrigin`] at this point, but that is exclusively + /// handled by the distributor. fn column_contents( &mut self, regions: Regions, balancing_target: Option, - ) -> FlowResult<(Frame, Abs)> { + ) -> Result<(Frame, Abs), RelayoutStop> { // Process pending footnotes. for note in std::mem::take(&mut self.work.footnotes) { - self.footnote(note, &mut regions.clone(), Abs::zero(), false)?; + self.footnote(note, &mut regions.clone(), Abs::zero(), Migration::FORBID)?; } // Process pending floats. for placed in std::mem::take(&mut self.work.floats) { - self.float(placed, ®ions, false, false)?; + self.float(placed, ®ions, false, Migration::FORBID)?; } distribute(self, regions, balancing_target) @@ -325,27 +448,27 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { /// Lays out an item with floating placement. /// /// This is called from within [`distribute()`]. When the float fits, this - /// returns an `Err(Stop::Relayout(..))`, which bubbles all the way through - /// distribution and is handled in [`Self::page`] or [`Self::column`] - /// (depending on `placed.scope`). + /// returns an `Err(FloatStop::Relayout(..))`, which bubbles all the way + /// through distribution and is handled in [`Self::page`] or + /// [`Self::column`] (depending on `placed.scope`). /// /// When the float does not fit, it is queued into `work.floats`. The /// value of `clearance` indicates that between the float and flow content /// is needed --- it is set if there are already distributed items. /// - /// The value of `migratable` determines whether footnotes within the float + /// The value of `migration` determines whether footnotes within the float /// should be allowed to prompt its migration if they don't fit in order to /// respect the footnote invariant (entries in the same page as the - /// references), triggering [`Stop::Finish`]. This is usually `true` within - /// the distributor, as it can handle that particular flow event, and - /// `false` elsewhere. - pub fn float( + /// references), triggering [`FloatStop::MigrateOrigin`]. This is usually + /// [`Migration::ALLOW`] within the distributor, as it can handle that + /// particular flow event, and [`Migration::FORBID`] elsewhere. + pub fn float( &mut self, placed: &'b PlacedChild<'a>, regions: &Regions, clearance: bool, - migratable: bool, - ) -> FlowResult<()> { + migration: Migration, + ) -> Result<(), FloatStop> { // If the float is already processed, skip it. let loc = placed.location(); if self.skipped(loc) { @@ -393,7 +516,7 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { } // Handle footnotes in the float. - self.footnotes(regions, &frame, need, false, migratable)?; + self.footnotes(regions, &frame, need, false, migration)?; // Determine the float's vertical alignment. We can unwrap the inner // `Option` because `Custom(None)` is checked for during collection. @@ -418,26 +541,27 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { area.skips.push(loc); // Trigger relayout. - Err(Stop::Relayout(placed.scope)) + Err(FloatStop::Relayout(placed.scope)) } /// Lays out footnotes in the `frame` if this is the root flow and there are /// any. The value of `breakable` indicates whether the element that /// produced the frame is breakable. If not, the frame is treated as atomic. /// - /// The value of `migratable` indicates whether footnote migration should be + /// The value of `migration` indicates whether footnote migration should be /// possible (at least for the first footnote found in the frame, as it is - /// forbidden for the second footnote onwards). It is usually `true` within - /// the distributor and `false` elsewhere, as the distributor can handle - /// [`Stop::Finish`] which is returned when migration is requested. - pub fn footnotes( + /// forbidden for the second footnote onwards). It is usually + /// [`Migration::ALLOW`] within the distributor and [`Migration::FORBID`] + /// elsewhere, as the distributor can handle [`FootnoteStop::MigrateOrigin`] + /// which is returned when migration is requested. + pub fn footnotes( &mut self, regions: &Regions, frame: &Frame, flow_need: Abs, breakable: bool, - migratable: bool, - ) -> FlowResult<()> { + mut migration: Migration, + ) -> Result<(), FootnoteStop> { // Footnotes are only supported at the root level. if self.config.mode != FlowMode::Root { return Ok(()); @@ -461,7 +585,9 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // The first footnote's origin frame should be migratable if the region // may progress (already checked by the footnote function) and if the // origin frame isn't breakable (checked here). - let mut migratable = migratable && !breakable; + if breakable { + migration.forbid(); + } for (y, elem) in notes { // The amount of space used by the in-flow content that contains the @@ -469,41 +595,42 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // the marker. For an unbreakable frame, it's the full height. let flow_need = if breakable { y } else { flow_need }; + // We only migrate the origin frame if the first footnote's first + // line didn't fit. + let migration = migration.take(); + // Process the footnote. - match self.footnote(elem, &mut regions, flow_need, migratable) { + match self.footnote(elem, &mut regions, flow_need, migration) { // The footnote was already processed or queued. Ok(()) => {} // First handle more footnotes before relayouting. - Err(Stop::Relayout(_)) => relayout = true, + Err(FootnoteStop::Relayout(ColumnScope)) => relayout = true, // Either of - // - A `Stop::Finish` indicating that the frame's origin element - // should migrate to uphold the footnote invariant. + // - A `FootnoteStop::MigrateOrigin` indicating that the frame's + // origin element should migrate to uphold the footnote + // invariant. // - A fatal error. err => return err, } - - // We only migrate the origin frame if the first footnote's first - // line didn't fit. - migratable = false; } // If this is set, we laid out at least one footnote, so we need a // relayout. if relayout { - return Err(Stop::Relayout(PlacementScope::Column)); + return Err(FootnoteStop::Relayout(ColumnScope)); } Ok(()) } /// Handles a single footnote. - fn footnote( + fn footnote( &mut self, elem: Packed, regions: &mut Regions, flow_need: Abs, - migratable: bool, - ) -> FlowResult<()> { + migration: Migration, + ) -> Result<(), FootnoteStop> { // Ignore reference footnotes and already processed ones. let loc = elem.location().unwrap(); if elem.is_ref() || self.skipped(loc) { @@ -577,8 +704,10 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // `regions.may_progress()` must still be checked separately for // migration, regardless of the presence of flow need. if first.is_empty() && exist_non_empty_frame { - if migratable && regions.may_progress() { - return Err(Stop::Finish(false)); + if let Some(m) = migration.into_inner() + && regions.may_progress() + { + return Err(FootnoteStop::MigrateOrigin(m)); } else if regions.may_progress() || !flow_need.is_zero() { self.footnote_queue.push(elem); return Ok(()); @@ -603,24 +732,25 @@ impl<'a, 'b> Composer<'a, 'b, '_, '_> { // Lay out nested footnotes. for (_, note) in nested { - match self.footnote(note, regions, flow_need, migratable) { + match self.footnote(note, regions, flow_need, migration) { // This footnote was already processed or queued. Ok(()) => {} // Footnotes always request a relayout when processed for the // first time, so we ignore a relayout request since we're // about to do so afterwards. Without this check, the first // inner footnote interrupts processing of the following ones. - Err(Stop::Relayout(_)) => {} + Err(FootnoteStop::Relayout(ColumnScope)) => {} // Either of - // - A `Stop::Finish` indicating that the frame's origin element - // should migrate to uphold the footnote invariant. + // - A `FootnoteStop::MigrateOrigin` indicating that the frame's + // origin element should migrate to uphold the footnote + // invariant. // - A fatal error. err => return err, } } // Since we laid out a footnote, we need a relayout. - Err(Stop::Relayout(PlacementScope::Column)) + Err(FootnoteStop::Relayout(ColumnScope)) } /// Handles spillover from a footnote. diff --git a/crates/typst-layout/src/flow/distribute.rs b/crates/typst-layout/src/flow/distribute.rs index c8f154e9e38e..21d6cbc4dd16 100644 --- a/crates/typst-layout/src/flow/distribute.rs +++ b/crates/typst-layout/src/flow/distribute.rs @@ -1,13 +1,51 @@ +use ecow::EcoVec; +use typst_library::diag::{SourceDiagnostic, SourceResult}; use typst_library::introspection::Tag; use typst_library::layout::{ - Abs, Axes, FixedAlignment, Fr, Frame, FrameItem, Point, Region, Regions, Rel, Size, + Abs, Axes, FixedAlignment, Fr, Frame, FrameItem, PlacementScope, Point, Region, + Regions, Rel, Size, }; use typst_utils::Numeric; -use super::{ - Child, Composer, FlowResult, LineChild, MultiChild, MultiSpill, PlacedChild, - SingleChild, Stop, Work, -}; +use super::compose::{Composer, InsertionStop, Migration, RelayoutStop}; +use super::{Child, LineChild, MultiChild, MultiSpill, PlacedChild, SingleChild, Work}; + +/// A control flow event during distribution. +enum Stop { + /// Indicates that the current subregion should be finished. + Finish(Finish), + /// Indicates that the given scope should be relayouted. + Relayout(PlacementScope), + /// A fatal error. + Error(EcoVec), +} + +/// The reason why the current region should finish. +enum Finish { + /// A lack of space. + Soft, + /// An explicit break. + Forced, +} + +impl From> for Stop +where + R: Into, +{ + fn from(stop: InsertionStop) -> Self { + match stop { + InsertionStop::Relayout(r) => Self::Relayout(r.into()), + InsertionStop::MigrateOrigin(()) => Self::Finish(Finish::Soft), + InsertionStop::Error(error) => Self::Error(error), + } + } +} + +impl From> for Stop { + fn from(error: EcoVec) -> Self { + Self::Error(error) + } +} /// Distributes as many children as fit from `composer.work` into the first /// region and returns the resulting frame and the height actually used @@ -16,7 +54,7 @@ pub fn distribute( composer: &mut Composer, regions: Regions, balancing_target: Option, -) -> FlowResult<(Frame, Abs)> { +) -> Result<(Frame, Abs), RelayoutStop> { let mut distributor = Distributor { composer, regions, @@ -29,11 +67,14 @@ pub fn distribute( let init = distributor.snapshot(); let forced = match distributor.run() { Ok(()) => distributor.composer.work.done(), - Err(Stop::Finish(forced)) => forced, - Err(err) => return Err(err), + Err(Stop::Finish(finish)) => matches!(finish, Finish::Forced), + Err(Stop::Relayout(scope)) => return Err(RelayoutStop::Relayout(scope)), + Err(Stop::Error(error)) => return Err(RelayoutStop::Error(error)), }; let region = Region::new(regions.size, regions.expand); - distributor.finalize(region, init, forced) + distributor + .finalize(region, init, forced) + .map_err(RelayoutStop::Error) } /// State for distribution. @@ -116,7 +157,7 @@ impl Item<'_, '_> { impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { /// Distributes content into the region. - fn run(&mut self) -> FlowResult<()> { + fn run(&mut self) -> Result<(), Stop> { // First, handle spill of a breakable block. if let Some(spill) = self.composer.work.spill.take() { self.multi_spill(spill)?; @@ -135,11 +176,11 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { /// Processes a single child. /// /// - Returns `Ok(())` if the child was successfully processed. - /// - Returns `Err(Stop::Finish)` if a region break should be triggered. + /// - Returns `Err(Stop::Finish(_))` if a region break should be triggered. /// - Returns `Err(Stop::Relayout(_))` if the region needs to be relayouted /// due to an insertion (float/footnote). /// - Returns `Err(Stop::Error(_))` if there was a fatal error. - fn child(&mut self, child: &'b Child<'a>) -> FlowResult<()> { + fn child(&mut self, child: &'b Child<'a>) -> Result<(), Stop> { match child { Child::Tag(tag) => self.tag(tag), Child::Rel(amount, weakness) => self.rel(*amount, *weakness), @@ -300,11 +341,11 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { } /// Processes a line of a paragraph. - fn line(&mut self, line: &'b LineChild) -> FlowResult<()> { + fn line(&mut self, line: &'b LineChild) -> Result<(), Stop> { // If the line doesn't fit and a followup region may improve things, // finish the region. if !self.fits(line.frame.height()) && self.regions.may_progress() { - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } // If the line's need, which includes its own height and that of @@ -318,14 +359,14 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { .nth(1) .is_some_and(|region| region.y.fits(line.need)) { - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } self.frame(line.frame.clone(), line.align, false, false) } /// Processes an unbreakable block. - fn single(&mut self, single: &'b SingleChild<'a>) -> FlowResult<()> { + fn single(&mut self, single: &'b SingleChild<'a>) -> Result<(), Stop> { // Lay out the block. let frame = single.layout( self.composer.engine, @@ -334,8 +375,13 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // Handle fractionally sized blocks. if let Some(fr) = single.fr { - self.composer - .footnotes(&self.regions, &frame, Abs::zero(), false, true)?; + self.composer.footnotes( + &self.regions, + &frame, + Abs::zero(), + false, + Migration::ALLOW, + )?; self.flush_tags(); self.items.push(Item::Fr(fr, 0, Some(single))); return Ok(()); @@ -344,14 +390,14 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // If the block doesn't fit and a followup region may improve things, // finish the region. if !self.fits(frame.height()) && self.regions.may_progress() { - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } self.frame(frame, single.align, single.sticky, false) } /// Processes a breakable block. - fn multi(&mut self, multi: &'b MultiChild<'a>) -> FlowResult<()> { + fn multi(&mut self, multi: &'b MultiChild<'a>) -> Result<(), Stop> { let mut pod = self.regions; // For column balancing, reduce the region size for layout. @@ -363,7 +409,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // Skip directly if the region is already (over)full. `line` and // `single` implicitly do this through their `fits` checks. if pod.is_full() { - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } // Lay out the block. @@ -375,7 +421,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // If the first frame is empty, but there are non-empty frames in // the spill, the whole child should be put in the next region to // avoid any invisible orphans at the end of this region. - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } self.frame(frame, multi.align, multi.sticky, true)?; @@ -385,14 +431,14 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { if let Some(spill) = spill { self.composer.work.spill = Some(spill); self.composer.work.advance(); - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } Ok(()) } /// Processes spillover from a breakable block. - fn multi_spill(&mut self, spill: MultiSpill<'a, 'b>) -> FlowResult<()> { + fn multi_spill(&mut self, spill: MultiSpill<'a, 'b>) -> Result<(), Stop> { let mut pod = self.regions; // For column balancing, reduce the region size for layout. @@ -404,7 +450,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // Skip directly if the region is already (over)full. if pod.is_full() { self.composer.work.spill = Some(spill); - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } // Lay out the spilled remains. @@ -416,7 +462,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { // region. if let Some(spill) = spill { self.composer.work.spill = Some(spill); - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } Ok(()) @@ -429,7 +475,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { align: Axes, sticky: bool, breakable: bool, - ) -> FlowResult<()> { + ) -> Result<(), Stop> { // If the frame is sticky and we haven't remembered a preceding sticky // element, make a checkpoint which we can restore should we end on // this sticky element. @@ -465,7 +511,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { &frame, frame.height(), breakable, - true, + Migration::ALLOW, )?; if !sticky && !frame.is_empty() { @@ -485,7 +531,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { } /// Processes an absolutely or floatingly placed child. - fn placed(&mut self, placed: &'b PlacedChild<'a>) -> FlowResult<()> { + fn placed(&mut self, placed: &'b PlacedChild<'a>) -> Result<(), Stop> { if placed.float { // If the element is floatingly placed, let the composer handle it. // It might require relayout because the area available for @@ -498,13 +544,18 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { placed, &self.regions, self.items.iter().any(|item| matches!(item, Item::Frame(..))), - true, + Migration::ALLOW, )?; self.use_height(weak_spacing); } else { let frame = placed.layout(self.composer.engine, self.regions.base())?; - self.composer - .footnotes(&self.regions, &frame, Abs::zero(), true, true)?; + self.composer.footnotes( + &self.regions, + &frame, + Abs::zero(), + true, + Migration::ALLOW, + )?; self.flush_tags(); self.items.push(Item::Placed(frame, placed)); } @@ -512,23 +563,23 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { } /// Processes a float flush. - fn flush(&mut self) -> FlowResult<()> { + fn flush(&mut self) -> Result<(), Stop> { // If there are still pending floats, finish the region instead of // adding more content to it. if !self.composer.work.floats.is_empty() { - return Err(Stop::Finish(false)); + return Err(Stop::Finish(Finish::Soft)); } Ok(()) } /// Processes a column break. - fn break_(&mut self, weak: bool) -> FlowResult<()> { + fn break_(&mut self, weak: bool) -> Result<(), Stop> { // If there is a region to break into, break into it. if (!weak || !self.items.is_empty()) && (!self.regions.backlog.is_empty() || self.regions.last.is_some()) { self.composer.work.advance(); - return Err(Stop::Finish(true)); + return Err(Stop::Finish(Finish::Forced)); } Ok(()) } @@ -541,7 +592,7 @@ impl<'a, 'b> Distributor<'a, 'b, '_, '_, '_> { region: Region, init: DistributionSnapshot<'a, 'b>, forced: bool, - ) -> FlowResult<(Frame, Abs)> { + ) -> SourceResult<(Frame, Abs)> { if forced { // If this is the very end of the flow, flush pending tags. self.flush_tags(); diff --git a/crates/typst-layout/src/flow/mod.rs b/crates/typst-layout/src/flow/mod.rs index 611b6c99156a..a36279850803 100644 --- a/crates/typst-layout/src/flow/mod.rs +++ b/crates/typst-layout/src/flow/mod.rs @@ -14,15 +14,15 @@ use bumpalo::Bump; use comemo::{Track, Tracked, TrackedMut}; use ecow::EcoVec; use rustc_hash::FxHashSet; -use typst_library::diag::{At, SourceDiagnostic, SourceResult, bail}; +use typst_library::diag::{At, SourceResult, bail}; use typst_library::engine::{Engine, Route, Sink, Traced}; use typst_library::foundations::{Content, Packed, Resolve, StyleChain}; use typst_library::introspection::{ Introspector, Location, Locator, LocatorLink, SplitLocator, Tag, }; use typst_library::layout::{ - Abs, Angle, ColumnsElem, Dir, Em, Fragment, Frame, HAlignment, PageElem, - PlacementScope, Region, Regions, Rel, Size, VAlignment, + Abs, Angle, ColumnsElem, Dir, Em, Fragment, Frame, HAlignment, PageElem, Region, + Regions, Rel, Size, VAlignment, }; use typst_library::model::{ ArtifactKind, FootnoteElem, FootnoteEntry, LineNumberingScope, ParLine, @@ -37,8 +37,7 @@ use self::block::{layout_multi_block, layout_single_block}; use self::collect::{ Child, LineChild, MultiChild, MultiSpill, PlacedChild, SingleChild, collect, }; -use self::compose::{Composer, compose}; -use self::distribute::distribute; +use self::compose::compose; /// Lays out content into a single region, producing a single frame. pub fn layout_frame( @@ -444,26 +443,3 @@ struct LineNumberConfig { /// `2.5em`. default_clearance: Abs, } - -/// The result type for flow layout. -/// -/// The `Err(_)` variant incorporate control flow events for finishing and -/// relayouting regions. -type FlowResult = Result; - -/// A control flow event during flow layout. -enum Stop { - /// Indicates that the current subregion should be finished. Can be caused - /// by a lack of space (`false`) or an explicit column break (`true`). - Finish(bool), - /// Indicates that the given scope should be relayouted. - Relayout(PlacementScope), - /// A fatal error. - Error(EcoVec), -} - -impl From> for Stop { - fn from(error: EcoVec) -> Self { - Stop::Error(error) - } -} From c52278d2c37bc74d08ef20cefea19b079bf9e055 Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:32:45 +0000 Subject: [PATCH 093/105] Improve documentation for `text.number-type` (#8821) --- crates/typst-library/src/text/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/text/mod.rs b/crates/typst-library/src/text/mod.rs index c9ce12bf0f10..4b9cf91f414b 100644 --- a/crates/typst-library/src/text/mod.rs +++ b/crates/typst-library/src/text/mod.rs @@ -707,10 +707,10 @@ pub struct TextElem { /// ```example /// #set text(font: "Noto Sans", 20pt) /// #set text(number-type: "lining") - /// Number 9. + /// Lining: 0123456789. /// /// #set text(number-type: "old-style") - /// Number 9. + /// Old style: 0123456789. /// ``` #[ghost] pub number_type: Smart, From 6f6923dafb7659239a6cebf164e8223532a9f543 Mon Sep 17 00:00:00 2001 From: David <121066936+deivisCode@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:34:15 +0000 Subject: [PATCH 094/105] Bump hypher to 0.1.8 (#8863) --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a4281cfdd037..a9f9d8aee709 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1118,9 +1118,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hypher" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b24ad5637230df201ab1034d593f1d09bf7f2a9274f2e8897638078579f4265" +checksum = "45f2b4f6b5da34d400d4c112eadcc14512cc5d04307a8eee182586d9bebd7ce6" [[package]] name = "iana-time-zone" diff --git a/Cargo.toml b/Cargo.toml index c224642b28e9..c6462142be88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,7 +66,7 @@ hayro = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea hayro-svg = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea15497effb2a884fb1608", default-features = false } hayro-syntax = { git = "https://github.com/LaurenzV/hayro", rev = "d8e24e29eda62581e1ea15497effb2a884fb1608" } heck = "0.5" -hypher = "0.1.4" +hypher = "0.1.8" icu_collator = { version = "2.3.1" } icu_properties = { version = "2.2.0", features = ["serde"] } icu_provider = { version = "2.2.0", features = ["sync"] } From 5d5ed38af5316b305525a4fce54a336306a862f2 Mon Sep 17 00:00:00 2001 From: Samuel Huang Date: Fri, 18 Sep 2026 09:26:12 +0000 Subject: [PATCH 095/105] Fix `repr` of fully-auto stroke denoting a different value (#8858) Co-authored-by: Laurenz --- crates/typst-library/src/visualize/stroke.rs | 2 +- tests/suite/foundations/repr.typ | 2 ++ tests/suite/visualize/stroke.typ | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/visualize/stroke.rs b/crates/typst-library/src/visualize/stroke.rs index 33e63b7b7676..11d6bd474459 100644 --- a/crates/typst-library/src/visualize/stroke.rs +++ b/crates/typst-library/src/visualize/stroke.rs @@ -316,7 +316,7 @@ impl Repr for Stroke { } (Smart::Custom(paint), Smart::Auto) => r.push_str(&paint.repr()), (Smart::Auto, Smart::Custom(thickness)) => r.push_str(&thickness.repr()), - (Smart::Auto, Smart::Auto) => r.push_str("1pt + black"), + (Smart::Auto, Smart::Auto) => r.push_str("stroke()"), } } else { r.push('('); diff --git a/tests/suite/foundations/repr.typ b/tests/suite/foundations/repr.typ index c4f4c8094c5a..9eea66519c31 100644 --- a/tests/suite/foundations/repr.typ +++ b/tests/suite/foundations/repr.typ @@ -63,6 +63,8 @@ sequence( // Colors and strokes. #t(rgb("f7a205"), `rgb("#f7a205")`) #t(2pt + rgb("f7a205"), `2pt + rgb("#f7a205")`) +#t(stroke(), `stroke()`) +#t(stroke(cap: "round", thickness: 2pt), `(thickness: 2pt, cap: "round")`) #t(blue, `rgb("#0074d9")`) #t(color.linear-rgb(blue), `color.linear-rgb(0%, 17.46%, 69.39%)`) #t(oklab(blue), `oklab(56.22%, -0.05, -0.17)`) diff --git a/tests/suite/visualize/stroke.typ b/tests/suite/visualize/stroke.typ index ad4c08722dbe..2066e213cb89 100644 --- a/tests/suite/visualize/stroke.typ +++ b/tests/suite/visualize/stroke.typ @@ -156,6 +156,22 @@ square(radius: (top-left: 0pt, rest: 100pt)), ) +--- stroke-repr eval --- +#let cases = ( + stroke(), + stroke(2pt), + stroke(red), + stroke(2pt + red), + stroke(cap: "round"), + stroke(paint: blue, thickness: 2pt, dash: "dashed", miter-limit: 5.0) +) + +// While repr isn't guaranteed to roundtrip with eval, we make an effort and if +// we cast with the `stroke` constructor, it should work for all cases. +#for case in cases { + test(case, stroke(eval(repr(case)))) +} + --- issue-3700-deformed-stroke paged --- // Test shape fill & stroke for specific values that used to make the stroke // deformed. From bc086c6495d506d6fc0894e45f6a3b0b09813b07 Mon Sep 17 00:00:00 2001 From: Sigurd Date: Fri, 18 Sep 2026 09:35:03 +0000 Subject: [PATCH 096/105] Add link to attach from under/over docs (#8855) Co-authored-by: Laurenz --- docs/content/reference/library/math.typ | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/content/reference/library/math.typ b/docs/content/reference/library/math.typ index 0c242de94ef7..a5850a8d299e 100644 --- a/docs/content/reference/library/math.typ +++ b/docs/content/reference/library/math.typ @@ -64,6 +64,8 @@ Delimiters above or below parts of an equation. The braces and brackets further allow you to add an optional annotation below or above themselves. + + These functions are intended specifically for adding delimiters. If you want to place two arbitrary parts of an equation over or under one another, without delimiters, use the @math.attach[`attach`] function instead. ], ), ( From d8d484f985f970f10f910d9dd506bd5212aceb9a Mon Sep 17 00:00:00 2001 From: kszumilo <55688400+kszumilo@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:37:44 +0000 Subject: [PATCH 097/105] Add font exceptions for IBM Plex (#8856) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Karol Szumiło --- .../typst-library/src/text/font/exceptions.rs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/crates/typst-library/src/text/font/exceptions.rs b/crates/typst-library/src/text/font/exceptions.rs index c02f616af612..2f625eb7a6a9 100644 --- a/crates/typst-library/src/text/font/exceptions.rs +++ b/crates/typst-library/src/text/font/exceptions.rs @@ -65,6 +65,109 @@ static EXCEPTION_MAP: phf::Map<&'static str, Exception> = phf::phf_map! { .weight(700), "FandolSong-Bold" => Exception::new() .weight(700), + // IBM Plex + "IBMPlexMono-Medm" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-MedmItalic" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-SmBld" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-SmBldItalic" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-Text" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexMono-TextItalic" => Exception::new() + .family("IBM Plex Mono"), + "IBMPlexSans-Medm" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-MedmItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-SmBld" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-SmBldItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-Text" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSans-TextItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansArabic-Medium" => Exception::new() + .family("IBM Plex Sans Arabic"), + "IBMPlexSansArabic-SemiBold" => Exception::new() + .family("IBM Plex Sans Arabic"), + "IBMPlexSansArabic-Text" => Exception::new() + .family("IBM Plex Sans Arabic"), + "IBMPlexSansCond-Medm" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-MedmItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-SmBld" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-SmBldItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-Text" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansCond-TextItalic" => Exception::new() + .family("IBM Plex Sans"), + "IBMPlexSansDevanagari-Medm" => Exception::new() + .family("IBM Plex Sans Devanagari"), + "IBMPlexSansDevanagari-SmBld" => Exception::new() + .family("IBM Plex Sans Devanagari"), + "IBMPlexSansDevanagari-Text" => Exception::new() + .family("IBM Plex Sans Devanagari"), + "IBMPlexSansHebrew-Medm" => Exception::new() + .family("IBM Plex Sans Hebrew"), + "IBMPlexSansHebrew-SmBld" => Exception::new() + .family("IBM Plex Sans Hebrew"), + "IBMPlexSansHebrew-Text" => Exception::new() + .family("IBM Plex Sans Hebrew"), + "IBMPlexSansJP-Medm" => Exception::new() + .family("IBM Plex Sans JP"), + "IBMPlexSansJP-SmBld" => Exception::new() + .family("IBM Plex Sans JP"), + "IBMPlexSansJP-Text" => Exception::new() + .family("IBM Plex Sans JP"), + "IBMPlexSansKR-Medm" => Exception::new() + .family("IBM Plex Sans KR"), + "IBMPlexSansKR-SmBld" => Exception::new() + .family("IBM Plex Sans KR"), + "IBMPlexSansKR-Text" => Exception::new() + .family("IBM Plex Sans KR"), + "IBMPlexSansSC-Medm" => Exception::new() + .family("IBM Plex Sans SC"), + "IBMPlexSansSC-SmBld" => Exception::new() + .family("IBM Plex Sans SC"), + "IBMPlexSansSC-Text" => Exception::new() + .family("IBM Plex Sans SC"), + "IBMPlexSansTC-Medm" => Exception::new() + .family("IBM Plex Sans TC"), + "IBMPlexSansTC-SmBld" => Exception::new() + .family("IBM Plex Sans TC"), + "IBMPlexSansTC-Text" => Exception::new() + .family("IBM Plex Sans TC"), + "IBMPlexSansThai-Medm" => Exception::new() + .family("IBM Plex Sans Thai"), + "IBMPlexSansThai-SmBld" => Exception::new() + .family("IBM Plex Sans Thai"), + "IBMPlexSansThai-Text" => Exception::new() + .family("IBM Plex Sans Thai"), + "IBMPlexSansThaiLooped-Medm" => Exception::new() + .family("IBM Plex Sans Thai Looped"), + "IBMPlexSansThaiLooped-SmBld" => Exception::new() + .family("IBM Plex Sans Thai Looped"), + "IBMPlexSansThaiLooped-Text" => Exception::new() + .family("IBM Plex Sans Thai Looped"), + "IBMPlexSerif-Medium" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-MediumItalic" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-SemiBold" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-SemiBoldItalic" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-Text" => Exception::new() + .family("IBM Plex Serif"), + "IBMPlexSerif-TextItalic" => Exception::new() + .family("IBM Plex Serif"), // Noto fonts "NotoNaskhArabicUISemi-Bold" => Exception::new() .family("Noto Naskh Arabic UI") From 2312927483a0575296bfd4c09793b055a01ff04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20Vo=C3=9Fhenrich?= <94936637+T1mVo@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:55:34 +0000 Subject: [PATCH 098/105] Add Nushell completions (#8810) Co-authored-by: Laurenz --- Cargo.lock | 82 ++++++++++++++++++++++++------------ Cargo.toml | 1 + crates/typst-cli/Cargo.toml | 2 + crates/typst-cli/build.rs | 4 +- crates/typst-cli/src/args.rs | 39 ++++++++++++++++- 5 files changed, 99 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a9f9d8aee709..764ff4428750 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -49,15 +49,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -346,9 +346,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.28" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e77c3243bd94243c03672cb5154667347c457ca271254724f9f393aee1c05ff" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -356,9 +356,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.27" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b26884eb4b57140e4d2d93652abfa49498b938b3c9179f9fc487b0acc3edad7" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -369,30 +369,40 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.5.44" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_complete_nushell" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375f9d8255adeeedd51053574fd8d4ba875ea5fa558e86617b07f09f1680c8b6" +checksum = "ffb66bc82eb9c92b1727310ae2c5868df22ae7cf46185bc5c544a4fa71955e49" dependencies = [ "clap", + "clap_complete", ] [[package]] name = "clap_derive" -version = "4.5.28" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.98", + "syn 3.0.3", ] [[package]] name = "clap_lex" -version = "0.7.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clap_mangen" @@ -1649,6 +1659,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + [[package]] name = "lipsum" version = "0.9.1" @@ -2220,9 +2236,9 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2474,7 +2490,20 @@ dependencies = [ "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.9.4", "windows-sys 0.59.0", ] @@ -2904,7 +2933,7 @@ dependencies = [ "fastrand", "getrandom 0.3.1", "once_cell", - "rustix", + "rustix 0.38.44", "windows-sys 0.59.0", ] @@ -2919,11 +2948,11 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.1" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix", + "rustix 1.0.8", "windows-sys 0.59.0", ] @@ -3177,6 +3206,7 @@ dependencies = [ "chrono", "clap", "clap_complete", + "clap_complete_nushell", "clap_mangen", "codespan-reporting", "color-print", @@ -4228,8 +4258,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e105d177a3871454f754b33bb0ee637ecaaac997446375fd3e5d43a2ed00c909" dependencies = [ "libc", - "linux-raw-sys", - "rustix", + "linux-raw-sys 0.4.15", + "rustix 0.38.44", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c6462142be88..7ef2f8a9dff9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ chrono = { version = "0.4.24", default-features = false, features = ["clock", "s ciborium = "0.2.1" clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } clap_complete = "4.2.1" +clap_complete_nushell = "4.6.2" clap_mangen = { version = "0.3.0", features = ["env"] } codespan-reporting = "0.11" codex = "0.3.0" diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index 2211746f8e77..7d9bab8d1763 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -32,6 +32,7 @@ typst-utils = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["string"] } clap_complete = { workspace = true } +clap_complete_nushell = { workspace = true } codespan-reporting = { workspace = true } color-print = { workspace = true } comemo = { workspace = true } @@ -79,6 +80,7 @@ tempfile = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["string"] } clap_complete = { workspace = true } +clap_complete_nushell = { workspace = true } clap_mangen = { workspace = true } color-print = { workspace = true } semver = { workspace = true } diff --git a/crates/typst-cli/build.rs b/crates/typst-cli/build.rs index ac18582adddc..4301177cc855 100644 --- a/crates/typst-cli/build.rs +++ b/crates/typst-cli/build.rs @@ -3,7 +3,7 @@ use std::fs::{File, create_dir_all}; use std::path::Path; use clap::{CommandFactory, ValueEnum}; -use clap_complete::{Shell, generate_to}; +use clap_complete::generate_to; use clap_mangen::Man; #[path = "src/args.rs"] @@ -31,7 +31,7 @@ fn main() { .unwrap(); } - for shell in Shell::value_variants() { + for shell in args::Shell::value_variants() { generate_to(*shell, cmd, "typst", out).unwrap(); } } diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 6104efb5ce72..2429c4f46d99 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -13,7 +13,7 @@ use std::str::FromStr; use clap::builder::styling::{AnsiColor, Effects}; use clap::builder::{Styles, TypedValueParser, ValueParser}; use clap::{ArgAction, Args, ColorChoice, Parser, Subcommand, ValueEnum, ValueHint}; -use clap_complete::Shell; +use clap_complete::Generator; use semver::Version; use serde::Serialize; use typst_utils::display_possible_values; @@ -279,6 +279,43 @@ pub struct CompletionsCommand { pub shell: Shell, } +/// Which shell to generate completions for. +#[derive(Debug, Copy, Clone, Eq, PartialEq, ValueEnum)] +#[clap(rename_all = "lowercase")] +#[expect(clippy::enum_variant_names)] +pub enum Shell { + Bash, + Elvish, + Fish, + Nushell, + PowerShell, + Zsh, +} + +impl Generator for Shell { + fn file_name(&self, name: &str) -> String { + match self { + Shell::Bash => clap_complete::shells::Bash.file_name(name), + Shell::Elvish => clap_complete::shells::Elvish.file_name(name), + Shell::Fish => clap_complete::shells::Fish.file_name(name), + Shell::Nushell => clap_complete_nushell::Nushell.file_name(name), + Shell::PowerShell => clap_complete::shells::PowerShell.file_name(name), + Shell::Zsh => clap_complete::shells::Zsh.file_name(name), + } + } + + fn generate(&self, cmd: &clap::Command, buf: &mut dyn Write) { + match self { + Shell::Bash => clap_complete::shells::Bash.generate(cmd, buf), + Shell::Elvish => clap_complete::shells::Elvish.generate(cmd, buf), + Shell::Fish => clap_complete::shells::Fish.generate(cmd, buf), + Shell::Nushell => clap_complete_nushell::Nushell.generate(cmd, buf), + Shell::PowerShell => clap_complete::shells::PowerShell.generate(cmd, buf), + Shell::Zsh => clap_complete::shells::Zsh.generate(cmd, buf), + } + } +} + /// Displays environment variables and default values Typst uses. #[derive(Debug, Clone, Parser)] pub struct InfoCommand { From db9b1de596ab18fa63507d4e2972e80ba9695dfe Mon Sep 17 00:00:00 2001 From: noClaps Date: Fri, 18 Sep 2026 13:10:55 +0000 Subject: [PATCH 099/105] Fix MathML CSS rules for matrix element spacing in multiline equations (#8851) --- crates/typst-html/src/mathml.rs | 38 +- tests/ref/html/hashes.txt | 737 ++++++++++++++++---------------- tests/suite/html/mathml.typ | 7 + 3 files changed, 395 insertions(+), 387 deletions(-) diff --git a/crates/typst-html/src/mathml.rs b/crates/typst-html/src/mathml.rs index 366fa1e27663..0a7222d7fbbc 100644 --- a/crates/typst-html/src/mathml.rs +++ b/crates/typst-html/src/mathml.rs @@ -100,31 +100,31 @@ pub(crate) static EQUATION_CSS_STYLES: LazyLock = LazyLock::new(|| { eco_format!( "\ /* Alignment */ -mtable.{RIGHT_ALIGN_CLASS} mtd, -mtable mtd.{RIGHT_ALIGN_CLASS}, -mtable.{LEFT_ALIGN_CLASS} mtd.{RIGHT_ALIGN_CLASS}, -mtable.{ALIGNED_CLASS} mtd:nth-child(odd) {{ +mtable.{RIGHT_ALIGN_CLASS} > mtr > mtd, +mtable > mtr > mtd.{RIGHT_ALIGN_CLASS}, +mtable.{LEFT_ALIGN_CLASS} > mtr > mtd.{RIGHT_ALIGN_CLASS}, +mtable.{ALIGNED_CLASS} > mtr > mtd:nth-child(odd) {{ justify-items: end; text-align: right; }} -mtable.{CASES_CLASS} mtd, -mtable.{LEFT_ALIGN_CLASS} mtd, -mtable mtd.{LEFT_ALIGN_CLASS}, -mtable.{ALIGNED_CLASS} mtd:nth-child(even), -math:is(:not([display])) > mtable.{MULTILINE_EQUATION_CLASS} mtd {{ +mtable.{CASES_CLASS} > mtr > mtd, +mtable.{LEFT_ALIGN_CLASS} > mtr > mtd, +mtable > mtr > mtd.{LEFT_ALIGN_CLASS}, +mtable.{ALIGNED_CLASS} > mtr > mtd:nth-child(even), +math:is(:not([display])) > mtable.{MULTILINE_EQUATION_CLASS} > mtr > mtd {{ justify-items: start; text-align: left; }} -mtable.{CASES_CLASS} mtd, -mtable.{ALIGNED_CLASS} mtd, -mtable mtd.{FLUSHED_CLASS}, -mtable mtd.{LEFT_FLUSH_CLASS} {{ +mtable.{CASES_CLASS} > mtr > mtd, +mtable.{ALIGNED_CLASS} > mtr > mtd, +mtable > mtr > mtd.{FLUSHED_CLASS}, +mtable > mtr > mtd.{LEFT_FLUSH_CLASS} {{ padding-left: 0; }} -mtable.{CASES_CLASS} mtd, -mtable.{ALIGNED_CLASS} mtd, -mtable mtd.{FLUSHED_CLASS}, -mtable mtd.{RIGHT_FLUSH_CLASS} {{ +mtable.{CASES_CLASS} > mtr > mtd, +mtable.{ALIGNED_CLASS} > mtr > mtd, +mtable > mtr > mtd.{FLUSHED_CLASS}, +mtable > mtr > mtd.{RIGHT_FLUSH_CLASS} {{ padding-right: 0; }} @@ -139,13 +139,13 @@ mtd {{ }} /* Equations */ -mtable.{MULTILINE_EQUATION_CLASS} mtd {{ +mtable.{MULTILINE_EQUATION_CLASS} > mtr > mtd {{ math-depth: inherit; math-style: inherit; math-shift: inherit; padding: 0; }} -math > mtable.{MULTILINE_EQUATION_CLASS} mtr:not(:last-child) mtd {{ +math > mtable.{MULTILINE_EQUATION_CLASS} > mtr:not(:last-child) > mtd {{ padding-bottom: {}; }} diff --git a/tests/ref/html/hashes.txt b/tests/ref/html/hashes.txt index 4adaea2ee2a0..89c93a5a8971 100644 --- a/tests/ref/html/hashes.txt +++ b/tests/ref/html/hashes.txt @@ -4,12 +4,12 @@ a845db3f6f6d466d683136b703f44cf6 basic-table 3f625e61698549ccccf65b72a66b72e7 bibliography-csl-display 74b33b507da27e15d265f3217c858ac6 bibliography-custom-title b7db257520089ca4fabc7de0ce145d4f bibliography-group-auto -02368f0b0c101c641df858196b440872 bibliography-group-citation-numbers-only -f6e1a9094c1ed21bef299f26aad195a2 bibliography-group-form-year -8997aa88a2a9c92d8e39674942922140 bibliography-group-full -12ec2dcc83fe5ee77a921565164d048e bibliography-group-full-custom-style -dfb3c710d089677eec69861ae5785a31 bibliography-group-mixed-full -c456aecc3b410f0b4f8705fc87e6d499 bibliography-group-mixed-styles +793712fa92f889e318b173fccc73807c bibliography-group-citation-numbers-only +fa0a8a3eb2b19f6f2be5eab5125ef532 bibliography-group-form-year +a5112eb6d4f36197028597a312a14c43 bibliography-group-full +f3e5b6eca7403e9729a4f90657896729 bibliography-group-full-custom-style +4c69a91621efb63782e69fa2c538ab82 bibliography-group-mixed-full +22987022d1b78322d79d7ff70d4e986e bibliography-group-mixed-styles 0b7c6366e0660d06550c9081512d5bb9 bibliography-group-none 5fab6af8d6370f60818ea4e1eec8095a bibliography-group-out-of-order 26e950c54e970fbac890d45eee54e320 bibliography-group-sorted-style @@ -77,25 +77,25 @@ f7eaf53ac8ae30b69426912e83ff1b43 image-blocky-html 7ce1a9fb2a3e2db824043a17d0de80da image-pdf-basic 9f48aacd22eb84ab8ec2e7f2d6afd263 image-scaling-methods f313175b86baa591804da764b851769e image-sizing-html-css -ebb2e91d7098e524109cd9f005579e8a issue-1052-math-number-spacing -261b7188e11dafe77fc61c38426592d9 issue-1617-mat-align -40f8581350cacee0e9cbee2adec51e87 issue-1948-math-text-break -9b4c5d0b7270b7eb5eb0ef976b4ebdaf issue-2044-invalid-parsed-ident -04e7e1693c634d113496c9c6eb87493c issue-2268-mat-augment-color -b7d456aefc311e9b3a7e4601600b830d issue-3696-equation-rtl -a2cf948fa779660fd8009346b61bfd25 issue-3774-math-call-empty-2d-args -112f4450ec86709e165ffeb5605efa1e issue-3973-math-equation-align -af593393cbd2ecd673f66d3d81d639af issue-4188-lr-corner-brackets -3e86412b8d14e6141d2a157e0b434fdb issue-4828-math-number-multi-char -52924bb3db10584a09819456462deb47 issue-4985-up-tack-is-normal-perp-is-relation -eaea8dbdff740aba6f875d2b1def88b5 issue-6090-math-overhang -cffe60d6afdbe66447c7db8982203273 issue-6170-equation-stroke -b1b17f3b81e381628200be8eee0a7e27 issue-7437-math-accent-text-presentation +bf728b3864bd88b648ac02e5319b98a3 issue-1052-math-number-spacing +49e0bbc59faa5e036c275468e8473014 issue-1617-mat-align +f451ae39bc1a93ae0d11d588a3b4ece0 issue-1948-math-text-break +f983d9194eab0697e23185a4816291db issue-2044-invalid-parsed-ident +c6ec4a935f78d459dc270c108f516c08 issue-2268-mat-augment-color +599a260f9128aee00e13c13b44856c70 issue-3696-equation-rtl +e97f69d2f07ec50d1bc7ba2e8c9f5430 issue-3774-math-call-empty-2d-args +a606359ed2edd035026fc92f0fc9fd89 issue-3973-math-equation-align +9b9cc62e1c579a65d36cde16496ad4fb issue-4188-lr-corner-brackets +c7fdf53834e30a25e43dd1d2e08590fe issue-4828-math-number-multi-char +0b59c30e66c5370819c6d5d695bb835a issue-4985-up-tack-is-normal-perp-is-relation +325212ec038fa5008495a7ecc678cf79 issue-6090-math-overhang +8c0b68b8c3a7633f72fb0ccd7afaec3f issue-6170-equation-stroke +33952b2eeda5761fca8d943d0d8721ef issue-7437-math-accent-text-presentation 3277fec377f92a3ca4d6d78ad5bccfcc issue-7751-table-cell-show-rules -8e6507eafdb9c00755087dc76cd14f88 issue-8261-string-as-ellipsis -0686c3a510ddf969416d0931f1c0c760 issue-8261-string-as-empty -51719643a3ad5c5a58e9a93141653d28 issue-8261-string-as-non-numeric -08eef154f592feabbe8e2ef7915d1f73 issue-math-realize-scripting +42fe3442b2e9fb70eee9e95081864472 issue-8261-string-as-ellipsis +27960fa44fa1d820637471af9026db18 issue-8261-string-as-empty +4202c01a65f931707295df7ab2f59a23 issue-8261-string-as-non-numeric +58214d2c1fc3a98beab9feb2ab093bfa issue-math-realize-scripting 44fbc75996ca7154896d82a2cc32da32 link-basic a401e6e8dbbfb91c6fcd9f50b0812f00 link-html-frame aecca822229f41f10fdcfae3954413f1 link-html-frame-ref @@ -105,351 +105,352 @@ aecca822229f41f10fdcfae3954413f1 link-html-frame-ref 2a4bbc3c8c4f18c632909849abd6fc1d link-html-label-disambiguation 128fcc9c404014198f9fa29210e1864d link-html-nested-empty bcefcf7a18422abf0da566507d6080fc list-par -df1f8e682fe06efe28078e8beec14038 math-accent-align -bbade493e498eab7cea3610d84eee723 math-accent-bottom -2e0b5dabcfe990c4f66584de7ba1c024 math-accent-bottom-cramped -587ab2a66fa9a58d3ec3f46f9eed7a20 math-accent-bottom-high-base -a1a5c894847b41c3827d84f4db484065 math-accent-bottom-sized -dad60f12edc60ec3c52795131fd3abbb math-accent-bottom-subscript -2cc08270ef1685a788c5e15b1e112a3e math-accent-bottom-wide-base -c98b7cd447c79959185178eff13c803d math-accent-bounds -bfbc98b81cf622a715b30888d710f09e math-accent-cramped -d5ad5460a6107753c02485bf87579d6f math-accent-dotless -88365e1fcc2da0baf72bb2494e67b07a math-accent-dotless-disabled -afb57fc7b560376fd2f3bbcaab024075 math-accent-dotless-greedy -c784e3dfbe0db183e493de0011df483f math-accent-dotless-set-rule -cc95c9432914c4a24cf981b20c01e275 math-accent-flattened -a06f293a2ddeda92a129b59bbe419847 math-accent-flattened-fallback -1e6f86051d1c7af399ce9c87b8cee459 math-accent-func -75e19dd5fa8e6ac1a28de00dcda5122b math-accent-high-base -d923bb402963d891de40e8aa7220ed91 math-accent-nested -32892fc607c41dbb19655abe51947ca8 math-accent-overlay -3a58f4922cebb15a40594a61d9adc9ae math-accent-show-rule-1 -965955df25b99d9f950c9b3abecc4cfc math-accent-show-rule-2 -3cca1ce06d7d69a5964f436dd111129c math-accent-show-rule-3 -eed23ca257c75de25373450b36ec7208 math-accent-sized -3bb162e46ebc0e3db6fc045a7b1ab8cd math-accent-sized-script -1810b4377b6f3f1f36c56e6ca203c960 math-accent-superscript -5b67efe34bd609fd2fa57da4ea22712f math-accent-sym-call -9b796a181531e8cca813910394772d98 math-accent-wide-base -7cb8412f6e80dc97931e7292c09ae657 math-align-aligned-in-source -81be885161b726fca7fbd626adf9508b math-align-basic -dacbad866412830eceb59f1b1c82cda6 math-align-cases -d4dfb05ef80cdd619dbf5a6ab255a0da math-align-implicit -f34f26f141ff57d6703341f8da6792a1 math-align-lines-mixed -45a45831b1fe86b8f6982b3df96e18b9 math-align-post-fix -3b4c2c4aa724adbc535d70dab75118c4 math-align-toggle -973fe573b47b1f84b4db3a9cb11a66e6 math-align-weird -1584734ea5d640715dc410d8e096a502 math-align-wider-first-column -4d9eb2ed89334491148e7152235b6023 math-at-par-end -b256c73f7cdff1a0f2df5e83021af52e math-at-par-start -1d08ca18e739d41ec8e1e7f385947e71 math-attach-default-placement -873e3a483425cb47ec6be0ae1cf4b813 math-attach-descender-collision -1b845acb06d61b906d2d3f001038a6f8 math-attach-double-chain -cd32bdc8fdf16e4e57180e85291da828 math-attach-followed-by-func-call -1223ccdb0bed58b83db5ea20d4aa0620 math-attach-force-scripts-and-limits -e284fd83844124102a4b5117a70a285f math-attach-high -1e799b452b2ab1b970251b51d9cafac1 math-attach-horizontal-align -c68ab4d1d87d0ee92874640958a707cf math-attach-integral -0021ea49016ae5162642e9479d9cdf76 math-attach-kerning -dee29067304baa5f9ef5711ecdfea8dc math-attach-kerning-mixed -852377a38d33d4f2a8a7ed9d30d65bc3 math-attach-large-operator -32b8a4be80a46e222fb32f42355ca1fc math-attach-limit -499876c262e9220f9b75f7cf5b60e874 math-attach-limit-long -d5cf1fee60e79b2fca88942864913211 math-attach-mixed -60a74a11405f5b01dfc07289d347026e math-attach-nested -bc057d20001a515aee475794b4e85f9c math-attach-nested-base -e3e3c247fc234e3b1faf2ded9082e7b2 math-attach-nested-base-bad-placement -6a8b3cfa57e8c44dde36d88b284679d3 math-attach-nested-deep-base -1d7359e974c32a11b2ca3e597250783f math-attach-postscripts -60a64a4a079b26b4df4c6921e835c2e5 math-attach-prescripts -eb7e705fdec94fb58bc15b8c6a37ef32 math-attach-scripts-extended-shapes -d3407e13837b87d5392615bed2874e0f math-attach-show-limit -0c21c4d6c023ee82a56270d78cb12806 math-attach-subscript-multiline -d03bf229ebc588cde37268aa69495a04 math-attach-to-group -7eeeb5961e75c57b49bc225c6d6dca36 math-binom -1cb75014b23cae8d0e62159fe1fe00c6 math-binom-multiple -5581f09b35e7c5171489259a238db1ae math-call-non-func -5d3751f01001fa74e8ef852a2b48e2b3 math-call-non-func-2d -f27cf50caa468e55451371d2472ad916 math-call-non-func-empty-args -7bc97f45e235f7f3d844797ee2641aff math-call-non-func-spacing -10c2064290a13d6194832b5acce961ea math-call-shadowed-builtin -3f9d20744d1c4f2e789f0105dff6faaf math-call-spread-shorthand-clash -100e65d4856a40763696345abfb46ebd math-cases -10d71bfc59dd5cb4c675bd0aad53a409 math-cases-delim -9ed0539190bdc91bebdca4f7d9ae87a3 math-cases-delim-class -b23d036bcbd46ec0e602231238ed6482 math-cases-gap -e82902ff68a2be3bd055709b4c7d82df math-cases-linebreaks -92f64aa47f4692037071638815fadac5 math-cases-linebreaks-trailing -75c1189acf8aafb26741666cfe9fc118 math-class-chars -77c2ffc684f94a533207a76132765b54 math-class-exceptions -2a29ee3a3cedaca4859b7fdf5dfa01a5 math-class-limits -766bf4baa8c6c3fbee5216a29fd1a223 math-class-nested -81a8382c4d901a49b3b7bcf37c32a56b math-class-not-recursive -a47c1172eb41e54f2caf7898579ccd9d math-common-symbols -0af60c585acf38434002a4ac8af41c35 math-consecutive -5309d0fc34a332d9f51b5c3bab1181b1 math-delim-show-rule-1 -fc62aa04468e80da7627a6aa0201e0b5 math-delim-show-rule-2 -e0decfbe5143f3822d8cfba99318c2f6 math-delim-show-rule-3 -02e968f4f151af4c1edcc6a70f57cc68 math-delim-show-rule-4 -357f39dc4b8fbec65d260cb341a47b5c math-delim-show-rule-5 -7fc420388da32a6b9c1d51044404646d math-dif -0b6830e8ffce26b10f84d96a541f1d9a math-equation-align-numbered -0b6830e8ffce26b10f84d96a541f1d9a math-equation-align-unnumbered -b3722b30124ab4b6bf6fbe2eda8e806f math-equation-auto-wrapping -bcbc8c616401df5ed0564f8e72e3a90b math-equation-font -617370a046bf33722a149c21a2cad16c math-equation-number-align -617370a046bf33722a149c21a2cad16c math-equation-number-align-end -617370a046bf33722a149c21a2cad16c math-equation-number-align-left -18971854caec07292c02f3ad47ffeecb math-equation-number-align-monoline -27ea04f5e82787ab5d7ededc3e5da7d3 math-equation-number-align-multiline -783c699c95ad3d7e6c0c8999508407a5 math-equation-number-align-multiline-bottom -588bca8b2dd08ac89093b729d691becf math-equation-number-align-multiline-expand -6735ed1ff526b513e43cdcdf7446cc73 math-equation-number-align-multiline-top-start -617370a046bf33722a149c21a2cad16c math-equation-number-align-right -617370a046bf33722a149c21a2cad16c math-equation-number-align-start -d967dd094c0c5464a1dae5b923a7c391 math-equation-numbering -86925fa496fe5178d94b1495f0155c82 math-equation-show-rule -f605062bc72a0da92dfde57a079d39cf math-font-covers -319ff855a24c2e66ba72a8f5fb9125dc math-font-fallback -da3760ec65a0823a2803d96f7af59646 math-font-fallback-class -62fa7949853b20dd76451ee5653f7eef math-font-features -bebc68786d017951ea64ce5ff9b9740d math-font-features-switch -88b2d977badfc9d08599c815d79fe5a2 math-font-switch -8b4d3db0e2ac877bd36efbf703ddb4f4 math-frac-associativity -94c2711a24a8ae15599e0e676ff02298 math-frac-baseline -6a4b004301dc2a6644a183ec14b41352 math-frac-gap -7686e9abe668f5d2a7d70bae29a3f8e2 math-frac-horizontal -432c1905f885ca757cf581044bd8da0f math-frac-horizontal-explicit -7e28e7052fa35ac4d6f3a58e764bf9a1 math-frac-horizontal-nonparen-brackets -c7ea7cae4028da138bbbb857be8b928d math-frac-implicit-func -13367af8a505ac1b43d01b3144005ac3 math-frac-large -2374e6e7222591ac9da550e53e42a562 math-frac-line-fill-stroke -f276fd69e166a905f6cc1e94262bfa71 math-frac-paren-removal -71ff317515871c68e3f288ab38c1c4a3 math-frac-precedence -8bbe7fd95e70ba8cfd3b792ef4d25200 math-frac-tan-sin-cos -02d607967cc6a627fade68d9db59cfca math-glyph-show-rule -8cc0df97235128caab4a1ffd573a152b math-linebreaking-empty -9a60ffd615304b8a8ea1bba5ad122ed0 math-lr-call -d34c7b4985ab14b0dc8b149aaae16022 math-lr-color -f9063aa18c3c6e5dd1f7ce3101997291 math-lr-fences -85652db1485c65281e9b06c408473d00 math-lr-half -bf4ba3fb1943f42fc128d9d7896a4f12 math-lr-ignore-ignorant -02becca349958284532f654cab2b666a math-lr-matching -54e74eb09ddeb68d9d8af32b92d6bd32 math-lr-mid -3a303e877f92e5d7e32c48bb4ad3a11b math-lr-mid-class -19598dc1aa43d4e6d2cd22ab91c3d5b5 math-lr-mid-size -e8268944f812b8cb52c33f3340632df4 math-lr-mid-size-nested-equation -63ec63e7e6558ad7af2d0a8819462fd1 math-lr-multiline -2c524febe3bf45d10e2a362778bee4bc math-lr-multiline-align -71919d038933a41eebc5d07113ef8b93 math-lr-multiline-trailing -a8ce522592318e1c2ca31b9559f81baf math-lr-nested -41f483205229cb9e9938499910b7d7c4 math-lr-nested-mid -abf4a5e72f6dd39b99be546080bddc9c math-lr-scripts -8508963350df18b09284b66e4fab5610 math-lr-shorthands -8a4286d3872be5791829a7f6e7b204b2 math-lr-single-line-align -3c0849fa5b2ea610bdb3ff80f88fd75f math-lr-size -3af85261f5583af580bb6359a9cf3591 math-lr-sym-call -989aae07d65ab05884be6fe40af5fc95 math-lr-sym-call-size -b9bdd39bc2ab7abf4b607ad8757ddbcc math-lr-symbol-unmatched -b980b9da63350fcfe75e31498d157058 math-lr-unbalanced -701bf4051e532d04bbe37c7ff89d6050 math-lr-unmatched -53544a1e2ea17e20dac097d6c30b450d math-lr-unparen -78914c49185f6c69f72cf228be8ead9f math-lr-weak-spacing -80078ab912fdcc9c329e83f62f320bba math-mat-align -03161a65950269cca7e70bd7ccc0a71c math-mat-align-complex -94f30c7ac0e5bf169f341e08293d6d48 math-mat-align-explicit-alternating -a40094eec0c720aefaf84a6a4d0d7290 math-mat-align-explicit-left -8c22e8476844553ef9b9edc9c781fde8 math-mat-align-explicit-mixed -e877760cda848095f90a17dc8b8588f9 math-mat-align-explicit-right -5deec63acea6b79291fa16973ea63fdf math-mat-align-implicit -cb0f39aa829d933aa65c12b61868a7b7 math-mat-align-signed-numbers -6e8344de3ffbdc2c9a7669be44aaabb9 math-mat-augment-set -9db11773784e55c177eba476b3e926a0 math-mat-bad-comma -ef818f04ceee458ca0e41b0e7fdb0028 math-mat-baseline -e344cefcacdffa790436fe2cfc9e6e3f math-mat-delim-class -0d3f27ee118d49e672a95ef3ff3c4bfa math-mat-delim-set -7f72dc403e007e57c4a060f6f650accd math-mat-delims -2cc621cce977f19349bc1b566d780b9c math-mat-delims-inverted -ad5d4bd0234f9078e5f57ebe3d9a72cc math-mat-delims-pair -7fc0ad43ddb331e7684b55266dab945d math-mat-gap -8587dcfda29480cbff5d0b7699529e34 math-mat-gaps -7c49144fbd0cfec06ed4d6432b49eca3 math-mat-linebreaks -ae688ecc4fdc9bc25edbde5c95576833 math-mat-linebreaks-trailing -d3a33520b89252b760ab1889b480d610 math-mat-semicolon -3138cc2f800b3d01ba8a74b3d6723309 math-mat-sparse -983a164dd89c82422a6f5c956f4479ad math-mat-spread-1d -ed211699fe490622be8f8cd772b24d25 math-mat-vec-cases-unity -d87286b2a08561c74b39e5e95d3041b8 math-multiline-multiple-trailing-linebreaks -ebade6bbd73d5f18726aa90621a0b6d9 math-multiline-no-trailing-linebreak -0a2d1c603463fcdaf6da12ea42e34e2b math-multiline-string -a0e191bb57c59f3849c52e90e3222a11 math-multiline-string-alignment -076bab747d1500e43db4ca3d64e4d80c math-multiline-trailing-linebreak -db9e75ac8bf47e497e061047217d9646 math-op-call -37ce1213def35ec23fac9e03f648cdf6 math-op-custom -d4c136297ed5531a4689d405c2188580 math-op-font -d2c049d9214a1f2e1d221e94c4546927 math-op-large-stretch -c9b26fdfab8ea05409faf4485ee538a9 math-op-predefined -e19edae4611059e579316a208253a63d math-op-scripts-vs-limits -28527d8f6dca88e3acb99f19c8559f71 math-op-set-font -318394fb00e5b89b3990cf5d1242a161 math-op-stretch-relative -d1fd44c94606331202c0d74f51d9a639 math-op-styled -85020cd77a6cf5b18a69d681bf66a021 math-op-symbol-baseline -be6fdcc59c937cd93c3e54f5fb25514e math-optical-size-frac-script-script -163cc355fe6c65b155ce0ae9cffd0a74 math-optical-size-nested-scripts -00f2bb64b6e1dd08a925489f52988332 math-optical-size-prime-large-operator -c2b164b3cd0fc8bd26e33f69b894da49 math-optical-size-primes -5dc9e9040250c35f73996b89dd88b203 math-primes -56addd6cc9def6f30e24dc2f5c18b6c8 math-primes-after-code-expr -b949b7b0f9cbe3ec2742f50325cd6009 math-primes-attach -ea3851e281da3beb0d65f7393a44f165 math-primes-complex -6d43d68d53cf83fcba383347333499df math-primes-factorial -dd1b7c0c6f3d059c7ff6e2cf84d5a772 math-primes-limits -23d187f8756edf4acd1e00475a5b9a1c math-primes-merge-inner-prime -b18768d480641954297ca8f528bba007 math-primes-merge-top -7bc3a79f28cfd58eddf3f6bbfaec5524 math-primes-merge-top-nested -711de01a664cd3f1c93806638eac104f math-primes-scripts -b80304d6399245b528acf3557482f220 math-primes-show-rule -0a3962abaf48b18e5f5f25148623ad19 math-primes-show-rules -81b3b6dd4fea4844064797c31e461d23 math-primes-spaces -7930f593b0d1b4edc677b7ea9d31bb72 math-primes-with-superscript -3d2d865593f3eae849256b0224e89df9 math-root-basic -5a41fb62250488ee2314da633c3dc193 math-root-frame-size-index -3c21e9a0d27cc3b7ba733515a8aaca54 math-root-index-cramped -0358d1898df653c527a997a8d59dcabf math-root-large-body -f40b0c316dc9ef3858ed176b133f264a math-root-large-index -1cd7bf8c0f29397864a8f90737d33acf math-root-line-fill-stroke -a11eac3d0f1a78d17c30efda44079036 math-root-precomposed -3b4cc5a54298a166e247d242ddfd42df math-root-radical-attachment -9de22dc2afe8249114c65906a74d60f0 math-root-show-rule-1 -436a494257908c1fc0341ac45917d40e math-root-show-rule-2 -6eac7931485e927625bdc39f5da3a1a8 math-root-show-rule-3 -62e5163cb1704c233dd18290257488ac math-root-show-rule-4 -b7f3831500e19a086c8597826d2bb08e math-root-show-rule-5 -773e80456023bdff77c6d45c590dc54b math-root-syntax -c7630afe204e0855c9fa00a115d79467 math-root-syntax-prec -ee1be869507746fa35b75e756ae5dc5e math-shorthands-noncontinuable -5375578d3401ae8076342f10381807c9 math-size -8df84c24e009d02d326429e9bc04af29 math-size-resolve -6f6d6cc4e294c0f1449058b62351dae8 math-size-space -ca2f5bde66b88ae071296e49c481a293 math-spacing-basic -0dae25eece98b7a2731adcfdf81c4a06 math-spacing-kept-spaces -d79fa5fa58e42c04a5ca62a8c313e4f5 math-spacing-predefined -e92f9612c5e7021bfa1be4f58dff668a math-spacing-script -beb9ff73d8aaa7170679c38e1a5fd99e math-spacing-set-comprehension -bf9547773150f62e2e3824c6d4f0c33d math-spacing-weak -09376e87e24d7e564d6ef2ac390348f3 math-spacing-weak-script -f0dd9b4b15d95e50d15b71f13b31c9ba math-stretch-attach-nested-equation -88ec267f8dc14cccab55aa35b9b0710c math-stretch-basic -c6f4f1caf46009fe16fc2746ab283b52 math-stretch-complex -33c9ee459f9b6de39ba073fba20efe43 math-stretch-explicit-mixed -132ea149920a76e79961b39b9901f268 math-stretch-horizontal -572fdb0e7106f0aec447a1b4ede2abba math-stretch-horizontal-attach -7d2717c7246a2cef811fd6248f65a043 math-stretch-horizontal-attach-constants -311b2e558fb4fffadafac7594f03eea1 math-stretch-lr-nested-horizontal -b8025bdcc95342f0076e97bad479719f math-stretch-lr-nested-horizontal-attach -dac421d6f72d8b3a793f710dce214c82 math-stretch-lr-nested-vertical -261dbc6c77840cf1f6ea7acfffcf9808 math-stretch-lr-nested-vertical-attach -ff3ca6c2d31487a016c8f607d137ffb1 math-stretch-nested -6bb26f9ad9773cd226c16aef620e9190 math-stretch-nested-horizontal-attach -c01decb213e769b25eaf988b65da63e3 math-stretch-nested-vertical-attach -c6488840009ca9c2359da7fe781043bd math-stretch-shorthand -d77daa5c043ac563ae3bc94b00f4b675 math-stretch-vertical -adcd4459225e2ed2cda74fa1e224532a math-stretch-vertical-attach -5cbed49a79ce93d96bc4f1d1f8973ac4 math-stretch-vertical-large-class -e2aaf6bf057151c4594068cb88cf9af3 math-stretch-vertical-scripts -9f0121932167a98c82b9f0fd8d53c2c7 math-style -637443dcb37be355d1339fcddd1c7aaa math-style-cluster -ccd0f729ea1974f078bf4826ffa96b5a math-style-dotless -c9f6021746a17e35b55f34aa1849a80b math-style-exceptions -7de32128a24e3d58d4b1c4916a678bc7 math-style-fallback -94165b2c555aaeb4e20c4bb410a602dc math-style-font-no-dotless -790a5377aaf531979f6704d15decb964 math-style-greek-exceptions -79c7a168dd1cf6468b40518ac65b4675 math-style-hebrew-exceptions -4edfb466607f564ff6581ee97a56cb37 math-style-italic-default -84d0a348def98f28cee70ab081dd84c2 math-style-script -1537ff15ab428cb917270943d1a3ec95 math-symbol-show-rule -ee8b3b901a7d109b891d68ac8675708c math-text-color -8a412dfcb1f3999abf4ee0c47eb1c0b8 math-text-single-grapheme-cluster -32375271e0daededc5845b6bfb16325c math-text-styled-num -a57ca58f0469ec518555a1e8796e6132 math-underover-brace -d8a5d0dbd19bee83d533a151acad718a math-underover-brackets -a34faa3a14fb77737c670315ed349cdc math-underover-multiline-annotation -291364f472f86a64643a42ad06f3cae0 math-underover-multiline-annotation-align -2655e0ae33195f0319d9c2ad2f18ab9e math-underover-parens -93381fa22f1afc3990c3975731df8500 math-underover-shells -7d9d0f1442e816a6da1365968889c21a math-unicode -2a7dc9714a5706aea6a195ea60d04a6b math-vec-align -94f30c7ac0e5bf169f341e08293d6d48 math-vec-align-explicit-alternating -e344cefcacdffa790436fe2cfc9e6e3f math-vec-delim-class -59e1c941c5b447e6f79a2da5bc7d33e9 math-vec-delim-set -d0df7fc368d4549733ed25e377c1ca2e math-vec-gap -7c49144fbd0cfec06ed4d6432b49eca3 math-vec-linebreaks -ae688ecc4fdc9bc25edbde5c95576833 math-vec-linebreaks-trailing -1dea648db2e1ce1c8868e8c9916a4399 math-vec-wide -1a6348d0c95c35b58403e2e10a63dcaf mathml-attach-single-op -80b9a3a5c91d4f792171bd5e2181e338 mathml-block-equation -e5445257ae7d39e6ea09a09b98099cbd mathml-box-block-equation-nested -86219e3ffd4c7d92a7c22b0bfcc40d54 mathml-box-equation -1b9f49e06fa701b6c5afbff51d1d9ccb mathml-custom-class -ba94f3dcfe98a2488cbfae5d230c4e7f mathml-custom-head-stylesheet -d379b9b098164fabbef194cd262ef5db mathml-dif -e0ef687d2456dd5b76ce6f7f6aa6417e mathml-equation-alignment -35badd3e37136de8a559c4364672085f mathml-equation-alignment-spacing -4dbc1e6b048353f7c662be84d540d2dd mathml-html-elem-show-rule -f5bbb9c70448b65a252d06c34d55e630 mathml-html-elem-styles -f9bb022b4bfde93fba06d7df3a561649 mathml-ignored-external-content -ff787a7dd5c684ab2c796f11905c4af6 mathml-ignored-math-content -d1311ec6aab62f8eb16d754e3e519215 mathml-inline-multiline-equation -4d46b93006591e07519c72f584647603 mathml-lr -9bcee59fe1826d95a02a6a3caa19ddb4 mathml-lr-size -7086e4442954d28443f0c642a8a8dc60 mathml-multiline-alignment-2 -a1415a1210cce6969daa96308df55723 mathml-multiline-alignment-3 -2a6633ad77e7e42b412cc2dfca3dd9a8 mathml-nested-elem-show-rule +4694a0ccfbb23fe3933e65c8b36bf74a math-accent-align +cc480aabeec1e4dfcd052be8441d8eae math-accent-bottom +5b04e79978a0773da201688bfebd9958 math-accent-bottom-cramped +8532bb298e3bc0079465fecea176fc2e math-accent-bottom-high-base +1085830ffaeee6a171e16ae27718328c math-accent-bottom-sized +f84010fa02f9055a4f6309e9a3a26fc7 math-accent-bottom-subscript +876d8949ef926bbf2ec95cbd71c0cb8d math-accent-bottom-wide-base +ddce767b1ad81d08dd2e261780ac4b09 math-accent-bounds +ac8e67003ccfd13804a6fb2fac17de1c math-accent-cramped +0394359deb8a350b9ae751fb7cab2ec2 math-accent-dotless +cae61f2d3b085503b09ccd3d5bd92002 math-accent-dotless-disabled +b681125efc0c6d7b177b55248598eaa2 math-accent-dotless-greedy +ea342a5261f078ceb4d045dc8a4e0aba math-accent-dotless-set-rule +b64c465588774117ac657b4a764f444b math-accent-flattened +4d8ce0fe4307be2992ded63e5f7e05d1 math-accent-flattened-fallback +a28f70a2760d7f8b7c1194298f860fb5 math-accent-func +f813b9712d23d4f2ce0a8dc176ce0be6 math-accent-high-base +97b154c642ee12728ff8a23743dd485c math-accent-nested +d0efcd22e305cdb966f1ee02ce6080d5 math-accent-overlay +65c089fc22bca46a06d8da90653f6bde math-accent-show-rule-1 +235d02902f74790b46b03319d596b72c math-accent-show-rule-2 +7b4d3b299ea3a60ee7a2eb7a9eee6043 math-accent-show-rule-3 +dad5c44a6e3a6cfb5338e4c705626440 math-accent-sized +610ff8c1e821826685e5c288a91e5d83 math-accent-sized-script +d72ebe9e1d6977cfd40eea253b80cf20 math-accent-superscript +8afd6466bbdf8f024d8daba346ccd149 math-accent-sym-call +5908d579ca1a710203ead2fa0c412408 math-accent-wide-base +d17cfe6ce8f30ff1b59929679d31ed0f math-align-aligned-in-source +8ab1ccf304ddc82e3ffd7299eb67893c math-align-basic +22cef15c68f4a0312b1d68c210955c24 math-align-cases +4eaddc6008720300cc5a390cfc372e6c math-align-implicit +14cf23976a206e598f6b7245a0969353 math-align-lines-mixed +46152db10acd83aa59ebdb648c6a81b8 math-align-post-fix +57b8e55ef48ec6129ad913cbd85a7dcf math-align-toggle +5d96a6f0c3f103c2c6e61b46ccde277e math-align-weird +411271e25af223edfcc3b76b97159190 math-align-wider-first-column +900113e594386f7551cfd969870a6fb4 math-at-par-end +1a5cdeaec63a7252c851b6f024cbfc50 math-at-par-start +b7d6ba97dcafabe29173da826e25ec0c math-attach-default-placement +23e62413cab17006bc9e60f213103ecb math-attach-descender-collision +a7b51372d828f112ff9214ebc00d824f math-attach-double-chain +d7d158ec72cdeddf85cf190a179cdaa9 math-attach-followed-by-func-call +d9f299b00172d6b893537078aa654142 math-attach-force-scripts-and-limits +2cb2fdf5dbb15971abad8024e42da4bc math-attach-high +2c3534155e78fd4d5dca757914f7aa0b math-attach-horizontal-align +1e9e2a0b3f9708a87ccc0abdfea0bd4c math-attach-integral +c310b52e70d4affa6823a79838790a46 math-attach-kerning +d40e0830494ae97086adde098f459932 math-attach-kerning-mixed +2c6b85889117bb727f1fa7d5fd008e4b math-attach-large-operator +c8ac20508dde137cf0185d10aae00242 math-attach-limit +86f146a5ecfccb082a34dbc2af9dfec6 math-attach-limit-long +a1885c32e329c19097b35739551a5657 math-attach-mixed +594bbfeda7b19feb83fc0cd3309c8c9b math-attach-nested +18c45db28d043c69a68ec9e6a50dc0f1 math-attach-nested-base +91e7127ce87e38f92c4c7a41294d8aee math-attach-nested-base-bad-placement +947c8f3d288f2f9c25225ba54fdbe044 math-attach-nested-deep-base +22b7ebb3ea6e5634fd812d48161f68d0 math-attach-postscripts +ae65fc013cb4b1d3089357fd45184084 math-attach-prescripts +76fd4460cbbbbe6ac598f8720c0aa6ac math-attach-scripts-extended-shapes +85b72b2a1e47ef880f9fde745e4b07fb math-attach-show-limit +c79cac6cb40e9288dd34b01f5b9c1c56 math-attach-subscript-multiline +ae8bea5f28d06133f5c83b806b228f1b math-attach-to-group +8666ad6356c54986728ba9b0ab19009b math-binom +8cacd29e0f2f82997e8020921895e221 math-binom-multiple +938f945f9aa1af0ed6ab5bc02a5f33bd math-call-non-func +790f0505b17eecf2fba5e8e6d8b5e9b6 math-call-non-func-2d +d432ddc35910dc8c019b264f155e0cf5 math-call-non-func-empty-args +334b211326302713794655db992fb05f math-call-non-func-spacing +c8abcefc664cdfce72ca4fa8523a810d math-call-shadowed-builtin +e3db34cb979e378e0214da969345d7be math-call-spread-shorthand-clash +20f646ed1b658d2dc5dc804f73cabfa0 math-cases +ead3397d695c524f413ad48cae1e9557 math-cases-delim +294c215436398020128f549c1b3f12e2 math-cases-delim-class +96be21757e186f047ab6f22a828877c9 math-cases-gap +5f58350b827a1279ff75d84f27de5a50 math-cases-linebreaks +8cadc370eeb6136d6d2f4e874ab07de2 math-cases-linebreaks-trailing +1c259b7c1bfabb46ee692ad84efd5a92 math-class-chars +1ea1284211d4811b526f7c8fe6b88d21 math-class-exceptions +c1f88224e0c5036ef1ded0854189e9b3 math-class-limits +20a0ee65462a7b55a0c3dc8682dfe130 math-class-nested +82e176a1ce8154a2d1ccb5ed4748fce1 math-class-not-recursive +55ed63382226e952b318835d2eec74d5 math-common-symbols +355516d4fc3b8ae71241c744755b94b4 math-consecutive +9de144a7edccd8a73a12203e127bfaa6 math-delim-show-rule-1 +594662aac8e15351a3653f1bcce09195 math-delim-show-rule-2 +fbd3ca675798da73761994c01486417d math-delim-show-rule-3 +8ae8a42ade595701c2fc10b020a09a9b math-delim-show-rule-4 +99455ea6f21f393dd3b671f31edbe34d math-delim-show-rule-5 +3037bfd1c13aba36ac960d1e1bdc583b math-dif +aa839b5ef6d595ad5f4bb55464149101 math-equation-align-numbered +aa839b5ef6d595ad5f4bb55464149101 math-equation-align-unnumbered +d012c48fc600202a963b49d409aa3b73 math-equation-auto-wrapping +389fa75cf12fc3cf6325c5f306f8fa2b math-equation-font +6f9fd773a683e0905d6d5adcfd693309 math-equation-number-align +6f9fd773a683e0905d6d5adcfd693309 math-equation-number-align-end +6f9fd773a683e0905d6d5adcfd693309 math-equation-number-align-left +b1f3abae0cfbe21a436731dda27c75d0 math-equation-number-align-monoline +a431a35f5f8ec39c9c7c33a5680c86b6 math-equation-number-align-multiline +451d4ce87b24d9e6f3cf0ec925eb7bed math-equation-number-align-multiline-bottom +7d552dcfd039434e32bc61c5fb00aff3 math-equation-number-align-multiline-expand +c314114e4b25a38e9ee744089919048d math-equation-number-align-multiline-top-start +6f9fd773a683e0905d6d5adcfd693309 math-equation-number-align-right +6f9fd773a683e0905d6d5adcfd693309 math-equation-number-align-start +b9af9dacaeb933c3975829546a14ee6f math-equation-numbering +a038ed65a536d1fc0d04e94d1c4a1e0d math-equation-show-rule +aa8afabd2f89b78bea73c41860e00bc4 math-font-covers +5f3ee9bcbaad1e5d14a4e524fbed5e03 math-font-fallback +dc0b7d3a54f848b29f43855c210ecae3 math-font-fallback-class +ceff2e8d875c62a48e879aead3d2aa06 math-font-features +b608bb8bb4847c46f1686e11e35782b6 math-font-features-switch +8d7a75b20e522ec521da054f1b7174c0 math-font-switch +63d7f2be7701cd7369bf3fbabeab99e4 math-frac-associativity +21fc0543e449d7878126bea91a6ef135 math-frac-baseline +adc137e71023f6c0c51ad7053131736c math-frac-gap +5fc6b70d7ec25ec2d2e1f68fb47991a6 math-frac-horizontal +ebb1e073c9083b0e4c51b407f7fb9482 math-frac-horizontal-explicit +ea5e9f4420ab775368dfec9519fef558 math-frac-horizontal-nonparen-brackets +7167a77f915baf165a17160d3a13b6f7 math-frac-implicit-func +0816fc44de87c03f2311efbb6f1b8338 math-frac-large +e5c6e8790f998b379aaeff6e36af8c64 math-frac-line-fill-stroke +6a172d16d338ec32808d938bd7bb1001 math-frac-paren-removal +95d4acda5a0ec0959f29972f27318bf8 math-frac-precedence +7ad4a4df0b1883cfe0dfbe6911bbc066 math-frac-tan-sin-cos +122f879080823703b0deea6f6df97539 math-glyph-show-rule +6c801ff06403ce544285d0e6f2de5079 math-linebreaking-empty +0599da489f72c1f2a27c5faa058cd475 math-lr-call +a05ede0bc6bd7a039e87fc3a288955d4 math-lr-color +272b99bf37579a8b0c88aa5281df4d66 math-lr-fences +31a49fcf63c0765c1365c9ec07e55723 math-lr-half +51123d83931c4f05f2de17e195fa201a math-lr-ignore-ignorant +467b2d41d91b26788fed5f4eb77bcf08 math-lr-matching +48157c3b575bc9f984434b7b9edef192 math-lr-mid +b0388902b46f353002c8a193bcfccc51 math-lr-mid-class +a35e7d22e92aa0786d7fc95e8035aa4e math-lr-mid-size +16bc5fca9b5c4fbed32bf76e0d5f50ba math-lr-mid-size-nested-equation +fdc4a8e7d03f56a4960d088c9dfac5f8 math-lr-multiline +08fd5718802094f0d86703ba2f5d5dfe math-lr-multiline-align +93e16485d0558cd9e87d7399925bf727 math-lr-multiline-trailing +a70cdf51e89645dcb15fcc0afedfd759 math-lr-nested +282d63e6d471895e01dd0317bed70e52 math-lr-nested-mid +e2568010c5bacbbf60afc4e38ccd4956 math-lr-scripts +bf074796d76edb1c4510d6bc14d2bfa5 math-lr-shorthands +629755fa9e95605fbb3ab1ada06729cc math-lr-single-line-align +208fb224b87ca66ce44c91e4e3faf021 math-lr-size +0c0f2f6f85a1f39bef8d662d2bc2f360 math-lr-sym-call +4d94877f903464aed9c1b0d09eb344ab math-lr-sym-call-size +0b8fbef29ce69d171710321ee314de34 math-lr-symbol-unmatched +8d184599a43ba21d146fbdbb4e752a01 math-lr-unbalanced +027ef78a298ade77be5e7d51c74cf718 math-lr-unmatched +5d3e74ed7652d9281d70fb51aa06cd8f math-lr-unparen +bcec852addf6fe772ded461d77fa3fa5 math-lr-weak-spacing +65d4e83f34b711386bbf399dc33b0657 math-mat-align +9625845810c9254dfbd378c7c1ae2b82 math-mat-align-complex +2c8136e9a33056e8f2388d18b27d9080 math-mat-align-explicit-alternating +c9db5e23018a7d7412d5579eb2679c87 math-mat-align-explicit-left +e7ad80bc6eaf9c680ef4beae37736117 math-mat-align-explicit-mixed +54dd5f4285d65fe9fcd04443c536473e math-mat-align-explicit-right +899063b740040e8ef11e65e9812cd418 math-mat-align-implicit +12f1ccf96740655e5f64907c456431c0 math-mat-align-signed-numbers +62325a32c25d136b3f6bfb1012171515 math-mat-augment-set +449c9fd9303d7c0e5a2f24287cb5856e math-mat-bad-comma +6f03a7777505b9166428ce9162d9a8e8 math-mat-baseline +7d65d042e30262cce38f23c06f22970d math-mat-delim-class +13a83af2d2dbfab3e2fdc4c700ba5110 math-mat-delim-set +461a473a6206ebab2dea548f23531166 math-mat-delims +dee7ebb83a38be0a7180e8eaf8c16b29 math-mat-delims-inverted +d72cabdfbfefbbd07bf9f8276679696c math-mat-delims-pair +2c03b95bef368ed7019c6ed9d782dca9 math-mat-gap +c025462bf443b7b775a7e8c2c8fabc3c math-mat-gaps +4b430d1a990e21f0aeba4c6e5775aecf math-mat-linebreaks +777f7cca931dd89e8700c1043b78cf53 math-mat-linebreaks-trailing +cc8a482a8e7b2bc3e329c31a839e9bb6 math-mat-semicolon +7da3d8c98fa5b562e75bb114416cb897 math-mat-sparse +faa79cf3f0cbb9e37b37df51b1cd9875 math-mat-spread-1d +c8a8a6744a887864d4bd89b28c964d3f math-mat-vec-cases-unity +4f74d6bab310f3955836362c8f03bc25 math-multiline-multiple-trailing-linebreaks +b9541091c460527350cb08b2057e87c9 math-multiline-no-trailing-linebreak +96e3f78c27f5cb31ee200da4f56563a3 math-multiline-string +0d1141a087085d1fcd1727ff5c6d312c math-multiline-string-alignment +9568e5ad7c415b0a2c0964c59af75b9f math-multiline-trailing-linebreak +0f73880fca615b9c83b40f306a5e8587 math-op-call +28bb3a3b0b8f0509e0da274d7d785a2c math-op-custom +7eb5e7ad5f82a26af9f5bf3165313d7d math-op-font +98f197a1c918c52ecadc23af721f3889 math-op-large-stretch +6c28ad1aaa805054e6211835b0b38d76 math-op-predefined +eee3664435e2ca7f84a317f9b71776a7 math-op-scripts-vs-limits +2233ef48eeda5a6def4abb38380eb040 math-op-set-font +b0780d055fee7ed809be0333b4a649b8 math-op-stretch-relative +7183d3949967049ab7c23c44abc73846 math-op-styled +91f5120a5297f26fc22f3d05805d6146 math-op-symbol-baseline +16109d1e9381526e73a3e953f2d96d71 math-optical-size-frac-script-script +27aa189c8da1a84ae3d46d4cd7f50d07 math-optical-size-nested-scripts +eb852535fc722b1a0711e7db2645d96e math-optical-size-prime-large-operator +6b3a0f802bc617af727a8ce3887af29e math-optical-size-primes +490cb26d23f60c604ee6de6a9ac6f596 math-primes +72cae61486e9d52716af1087bb8b8566 math-primes-after-code-expr +72a146796b5590bc4e5b51e8424eec69 math-primes-attach +55d73e41a455bc42e5d22e9c592b563f math-primes-complex +8eed56155201ad3167d9fd79c44d9647 math-primes-factorial +5e7d2ba2b74db51fb27468b9bf302cc7 math-primes-limits +f5e6dc502f6ca0897108e326e1f33146 math-primes-merge-inner-prime +69d0934dab1161dbd4a66a67c68a4ac7 math-primes-merge-top +af0e422c5cac787370588bfe28966848 math-primes-merge-top-nested +d1ee20a1adc463e4251f6c93d5e1b3c3 math-primes-scripts +274da5855791d6e33f0ccf9046a9b036 math-primes-show-rule +3d47685574a9c60f23d1ed9067271b00 math-primes-show-rules +71006deb3ec91888a2816590795ba9a9 math-primes-spaces +e1ec801c9447cd53d5308e1d7b8f8a3c math-primes-with-superscript +18522ee1156ad20e17e5f8dc7b2dd34e math-root-basic +9cfae107206c3de85a135c1215d0eab6 math-root-frame-size-index +44aad9986e45b2a28c8128d1ff16f5cb math-root-index-cramped +1ee6e0dc257148e3382c29a4892b85aa math-root-large-body +ba831f44d79d471ed37c60a3dd192d8c math-root-large-index +441b1b729a3c7d4e532eb7a620628b4b math-root-line-fill-stroke +d85f44bb5136eaab479ed40e6354c7d6 math-root-precomposed +5113a356df858c26de493c97c57f150c math-root-radical-attachment +963bd6c74193d61859b6045ac901d6f8 math-root-show-rule-1 +bfabd4822b54d453495c59635ce9ed8d math-root-show-rule-2 +0373363af6b023611ca0a329030ac8a4 math-root-show-rule-3 +40abcd362c1e5ff3a77eeb7f7332bd7f math-root-show-rule-4 +a9782715bc2e9bfa4a8da9d57981ff9f math-root-show-rule-5 +b6cf4d2722347e612f552567323351f5 math-root-syntax +43c011a46e175a97e1528d6355a86744 math-root-syntax-prec +37f01369c10bc5a888492d9a1e99f1b0 math-shorthands-noncontinuable +2c965f097f32098d6f5ce87b2145e881 math-size +602acbb1d440854ba2210898e300235e math-size-resolve +d4d7604e73bd76cc88013dce4e9afdce math-size-space +4c4f7e419b84180e0ae157e70c826703 math-spacing-basic +d9a5c0ae5eb442b41b7ed07698002c4b math-spacing-kept-spaces +f7cca3e66654f43d3afd02c3460bb173 math-spacing-predefined +5c49dd261cb993372d3cc18408fbc6bd math-spacing-script +dc6a1c6b3f44418c88104875348b88c8 math-spacing-set-comprehension +e18b3943d13f77392f5257fc96e25456 math-spacing-weak +0f00fbd9a8d0afaf643c4e5843d38b93 math-spacing-weak-script +4d3ec924b357c52911412a299ddfecc6 math-stretch-attach-nested-equation +b04668fcfcc22cd528c207cb885468b0 math-stretch-basic +f12fd5979177d632cae4076d2da901f3 math-stretch-complex +caa4ee3ec26f7390e2a9b721df8af3ce math-stretch-explicit-mixed +9ca4476ce62bc0f60d8bc22b7e78007e math-stretch-horizontal +fb63c6ca3550fa5ec498aaecead59a5a math-stretch-horizontal-attach +7d29c1035fba1467ba7737c5e3041612 math-stretch-horizontal-attach-constants +f01278a0f7dee62b76ad86958b19bf43 math-stretch-lr-nested-horizontal +48cae86bf1dc31e923e95cce53c5b2f4 math-stretch-lr-nested-horizontal-attach +1665ffec47aaf32adaf5dff9d526edf8 math-stretch-lr-nested-vertical +4fe310fc649bb09ff456b71ede620007 math-stretch-lr-nested-vertical-attach +2510d8b06670dbc7ee24278809425a87 math-stretch-nested +3aba7a71be2f2e1f78732807aa363e27 math-stretch-nested-horizontal-attach +94d7f0c38c683eebf0e9686786712ad6 math-stretch-nested-vertical-attach +2bedddf42c5c236164b9a56b932252a1 math-stretch-shorthand +d93951bf138522f3e1130daff1dccd62 math-stretch-vertical +593080645ed4cd96ae3bd03c46a44048 math-stretch-vertical-attach +52eb88349057f152cfdc2a43116a1f4c math-stretch-vertical-large-class +6c4f2dea38c937736c7768d189410799 math-stretch-vertical-scripts +0c994d3ad46da19b17f3c1072e955327 math-style +b372003d54faa032df1329e283edab21 math-style-cluster +bc24721600e4ac02f66f69a3f50507ff math-style-dotless +7bc48526b1e1697fd36edabb7455c3a5 math-style-exceptions +273a5c4ffcefbbde98097974ae2018f9 math-style-fallback +f677365afbf0d9d934be43a1b065052f math-style-font-no-dotless +2f6ced09053a7c79998ecfd311951e4c math-style-greek-exceptions +cb21b3b97f7ce9a41df5679ef521f997 math-style-hebrew-exceptions +90f2acb177ba65b6d7c45fb111ecf2bb math-style-italic-default +5d1e9a2907b9c1732f6a2304613284ec math-style-script +2ddbbbd0a0175562d34e96f1886e6277 math-symbol-show-rule +5fbe2db2e7257895fa8376a28dec380e math-text-color +ae3c217e313afbb56757131a40a8296d math-text-single-grapheme-cluster +f97cb2d2de9b58121bbfa5c14ce6e553 math-text-styled-num +b41fbe431c0af274d986ed35779f3410 math-underover-brace +a3a392f0693f2a5530626b1bb568a430 math-underover-brackets +b04049fdf7b65984c7055b3883c58669 math-underover-multiline-annotation +c25cebb97717981ab14be7c6ab395a4c math-underover-multiline-annotation-align +8c18304b78c6b3afd7382e6d01684050 math-underover-parens +99491d75072d24056052cd689541aed8 math-underover-shells +53e0792f15f8adcb8f2f460716477462 math-unicode +79239c4b0777d1e4a74cb75e3d6f2a1a math-vec-align +2c8136e9a33056e8f2388d18b27d9080 math-vec-align-explicit-alternating +7d65d042e30262cce38f23c06f22970d math-vec-delim-class +fa452c2e5398b612a9a9b05efe96b022 math-vec-delim-set +89cad5060190b08ceb8fafbc9585d154 math-vec-gap +4b430d1a990e21f0aeba4c6e5775aecf math-vec-linebreaks +777f7cca931dd89e8700c1043b78cf53 math-vec-linebreaks-trailing +155003c06aeb2d75f8531d788de0a84c math-vec-wide +5b213042dd590b9cde5b533fb5b11b4e mathml-attach-single-op +1c70e284154ac8e1ca69f7611d64ac7f mathml-block-equation +ca88e477dde6d9d56ee12f0edc2f2eb8 mathml-box-block-equation-nested +f0ff6189cad5295508f85d3cf849fd35 mathml-box-equation +69e50c79188c84c9094a4319888d2fa8 mathml-custom-class +06b03d89af384bec44c89b59291f278f mathml-custom-head-stylesheet +fe46e49571cb27747fe1bdacfdbc7999 mathml-dif +e552253412beb3eba9b8fc83edd10d18 mathml-equation-alignment +cca32e9c9351f3ed870776d39524f318 mathml-equation-alignment-spacing +4c6f8187d78d8e79431335d1d27480ea mathml-html-elem-show-rule +c09e79ebb01fa993ee5e86af7ba01a43 mathml-html-elem-styles +fcbcff4e849fc1892d0a232b756d144e mathml-ignored-external-content +f50a7ee8a6cfab0cd1552a223c1abe2f mathml-ignored-math-content +0ed7a948ec768ecd9f8107f5a35ad336 mathml-inline-multiline-equation +01efc2846635452ede7c0322057f64c8 mathml-lr +d8747e9078450efe3426a70564088276 mathml-lr-size +cfbd7ccc551d3e44233d43b6e7edcfd7 mathml-multiline-alignment-2 +0edcba6e6d812db3164e196688048e0c mathml-multiline-alignment-3 +e950201085ec2160011717b4280260d9 mathml-multiline-matrix-spacing +f6c1ba45bfb8cc68ef1addcded52c267 mathml-nested-elem-show-rule 46c0a9deeb72875e70d957f256eade0e mathml-non-math-in-math -2678cd89ff2cacd73cb048c4d42a2495 mathml-primes -5106a875b624d5d22d173f3b5eff78f5 mathml-primes-factorial-explicit-class -aa70e6e6dec9074fd30b6fe0002c7acf mathml-show-rule -9f2126bdba0246ce57364b0e73af515d mathml-show-rule-accent -ae1ec94243b16393e712c63a2e51afca mathml-show-rule-math-in-non-math-in-math -6ca02b4c8a157bdf99fa3e1e9fe8aaaf mathml-show-rule-non-math-in-math -431573e9e1c3b08713d89f7310479aff mathml-show-rule-non-math-in-math-in-math -39eed0e8337acae694f0d707cf460d87 mathml-spacing-resolve -37006afcd64aa4d984ddba41b9d36251 mathml-stretch -5b0cbcfaa36c2b70c605756f92356d50 mathml-stretch-largeop-vs-explicit -77e661105be2d5fbe2f6d789fd636984 mathml-stretch-vertical-alignment -e6577e7c22c5dde6ec3c19321cc16e3c mathml-text-op -46a2be63f0035822ba5b618bf893acfc mathml-vary-class-spacing -10c2eac82efcd0727207a519a70ef260 mozilla-mathml-test-1 -1ffe364ac81863381ecb52e260e17a53 mozilla-mathml-test-10 -a9816e937e9e556b85f6e8ed8b588f6d mozilla-mathml-test-11 -059ed097722b602566440cd3368a17f0 mozilla-mathml-test-12 -628ea8bfd2ef2d830f6d081fcb99ba47 mozilla-mathml-test-13 -7f1d0f25bdbd7f2d6a03e2d6de8146b9 mozilla-mathml-test-14 -34181aade95fc590c60015052325d42d mozilla-mathml-test-15 -d563a5b4c19f110dcf4aa2e4d404be97 mozilla-mathml-test-16 -1ccc57b5e72d938d275dc89894f1e359 mozilla-mathml-test-17 -23fbd5e22826ba5e5063f775952d0c03 mozilla-mathml-test-18 -c4886d0a487e2f1819aa8caf2b619bbf mozilla-mathml-test-19 -7612b4ba9ba1bed805cb4a68721842e4 mozilla-mathml-test-2 -cdfc44fdbd5b0dcfb9a75c501bd5a71c mozilla-mathml-test-20 -01d50c18314926f1ffca29418fa17d38 mozilla-mathml-test-21 -4579592bc8c989295eb8cb59dae3c5c2 mozilla-mathml-test-22 -ceac857a2ddc04350ece15a4452cec8b mozilla-mathml-test-23 -281b219c9c1144bec3cb3a31e27d5a37 mozilla-mathml-test-24 -26a413d00c5007238c15423b5f513625 mozilla-mathml-test-25 -1b4a2d174717e9bbdb50809205b67e8e mozilla-mathml-test-26 -79cd3496e8b314b84b19e36db011bfdc mozilla-mathml-test-27 -07557952456da7e2f5ab6e60c838fb1e mozilla-mathml-test-28 -96ec5a44d823d1a482b7aa875e145d9e mozilla-mathml-test-29 -2c3aa575e14745bea6465fb8c3531d01 mozilla-mathml-test-3 -2b1d34014ebe483b9eb83f3cde10ab80 mozilla-mathml-test-30 -27521b1c1d0951efb11b60fad89411a5 mozilla-mathml-test-4 -c80288e3a445f09afbd9844b16acaf26 mozilla-mathml-test-5 -67181321021fdbeddcebf1089ed66f6c mozilla-mathml-test-6 -1d9de9f730a93ec3a560a1991ad1cbe5 mozilla-mathml-test-7 -4085e9183394c806cab9ab9452f9d852 mozilla-mathml-test-8 -52e1ad1c3d7632a8f1e4e4a1abfc1949 mozilla-mathml-test-9 +2172a8961f2dc0a0d0f8f0a74e2ab269 mathml-primes +1631106a3f7a9602de17e8e6810f263d mathml-primes-factorial-explicit-class +5f0ac32aa6f637be7fb7a70096b839c1 mathml-show-rule +fec98da376f846e405f492c9cfca8b6a mathml-show-rule-accent +addbaafa26e078c69d0ae9ee89ed2e9b mathml-show-rule-math-in-non-math-in-math +d50662f1bb5c4d67ed95d506a301c740 mathml-show-rule-non-math-in-math +d14a047cad52116ac99c79b93416f64b mathml-show-rule-non-math-in-math-in-math +ffae75c14d802026b5d6bde79525e014 mathml-spacing-resolve +efe6af013d8322040cf97c9302631df2 mathml-stretch +97a59694a1fcae4c06a918ee0bdcab62 mathml-stretch-largeop-vs-explicit +b8c0d1484992a215deb34af9cde44131 mathml-stretch-vertical-alignment +c794db0fcbac57d3073fe1ee7508da4c mathml-text-op +03b33447c738f28e8656604cd138ded0 mathml-vary-class-spacing +0376acd3f50ba4cfb4763913bc1949c5 mozilla-mathml-test-1 +6fe4460f192eb0164686d61b809e111c mozilla-mathml-test-10 +7e12819fdcd79c5d8f13154d06f9ff4d mozilla-mathml-test-11 +e4eb0ba5f6226026a8a16e122a63b7d8 mozilla-mathml-test-12 +aeac9f82796b276f2d5d3054a255b88e mozilla-mathml-test-13 +3c9f46ff50123e0b61e65d6e6712783d mozilla-mathml-test-14 +830d6044aa3ee7652535d116c6d1a6f5 mozilla-mathml-test-15 +fd71e851dccbd6be0774e297846e8a17 mozilla-mathml-test-16 +46a060d24f06b8cb197776919afdf363 mozilla-mathml-test-17 +2ced7b20b62c5f8a97db34b779b9aa13 mozilla-mathml-test-18 +21125dc0529a170e83010b6fc140cb8b mozilla-mathml-test-19 +47ac7fb392ce4b78f411917dccdb0342 mozilla-mathml-test-2 +a38541d388603a517b5f508f82e4e709 mozilla-mathml-test-20 +acabc5659d844cb4428b16b0e02da18d mozilla-mathml-test-21 +f67951db9bbc42e06d0149e043f27fae mozilla-mathml-test-22 +05a964a72a036f06ed1d7656110cbac4 mozilla-mathml-test-23 +fc52af39355b0d4682103f97f541d3d7 mozilla-mathml-test-24 +728fa8c186d93498254e5b78e87b8a1e mozilla-mathml-test-25 +c3c88c3b1e77ec4f84667b239b316a27 mozilla-mathml-test-26 +5adbefeed65f24fb5ffd5d9e4a121c8e mozilla-mathml-test-27 +33d756137ec8552bfb7eaec97953e6a9 mozilla-mathml-test-28 +1c9cee1ea6aa188ead10fc2f0e78cc8c mozilla-mathml-test-29 +6ba6b4116f7edbbfe9f8b3f6641690f3 mozilla-mathml-test-3 +26b17c6e66b930f40c05d08d1f745bca mozilla-mathml-test-30 +f2795c78c93d98d3d504b12a13f901d1 mozilla-mathml-test-4 +4534442f9c924214a7b52999554b4abe mozilla-mathml-test-5 +1c481ca5f0d78c7538e3a2aa4098651f mozilla-mathml-test-6 +8b9bd8ed6ed4e62b354f70093ae52f8c mozilla-mathml-test-7 +9316d1240880c5b862a3ab8f65160c1e mozilla-mathml-test-8 +6826af4993d686c38d77a2589397a45a mozilla-mathml-test-9 1c10cabdf078e21840dec9d575164bc7 multi-header-inside-table a2f8476b6a959f51028a2531b16059e5 multi-header-table a2f8476b6a959f51028a2531b16059e5 multi-header-table-gutter diff --git a/tests/suite/html/mathml.typ b/tests/suite/html/mathml.typ index 9fd7de16bb13..c16c7f37a615 100644 --- a/tests/suite/html/mathml.typ +++ b/tests/suite/html/mathml.typ @@ -46,6 +46,13 @@ $ d & = e + f $ +--- mathml-multiline-matrix-spacing html --- +$ + "square matrix" & = mat(1, 0; 0, 1) \ + "array" & = mat(1, 0) \ + "vector" & = vec(1, 0) +$ + --- mathml-stretch-largeop-vs-explicit html --- $ integral $ $ stretch(integral) $ From 8df1d5993d337817a1834d769eff38cf263bec53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=A1=A5=E1=A0=A0=E1=A1=B3=E1=A1=A4=E1=A1=B3=E1=A0=B6?= =?UTF-8?q?=E1=A0=A0=20=E1=A1=A5=E1=A0=A0=E1=A0=AF=E1=A0=A0=C2=B7=E1=A0=A8?= =?UTF-8?q?=E1=A1=9D=E1=A1=B4=E1=A0=A3=20=E7=8C=AB?= Date: Fri, 18 Sep 2026 13:20:48 +0000 Subject: [PATCH 100/105] Add Standard Zhuang translations (#8802) --- crates/typst-library/src/text/lang.rs | 1 + crates/typst-library/translations/za.txt | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 crates/typst-library/translations/za.txt diff --git a/crates/typst-library/src/text/lang.rs b/crates/typst-library/src/text/lang.rs index 0f4311b0b04c..659f94270a14 100644 --- a/crates/typst-library/src/text/lang.rs +++ b/crates/typst-library/src/text/lang.rs @@ -113,6 +113,7 @@ const TRANSLATIONS: &[(&str, &str)] = &[ translation!("uk"), translation!("ur"), translation!("vi"), + translation!("za"), translation!("zh"), translation!("zh-TW"), ]; diff --git a/crates/typst-library/translations/za.txt b/crates/typst-library/translations/za.txt new file mode 100644 index 000000000000..6284ccc78a18 --- /dev/null +++ b/crates/typst-library/translations/za.txt @@ -0,0 +1,11 @@ +figure = Doz +table = Biuj +equation = Daengjsik +bibliography = Vwnzyen Doiqciuq +heading = Ciet +outline = Moegloeg +raw = Daihmax +page = yieb +footnote = Gejnaeuz +email = E-mail +telephone = Denva From 4858410437f521893069a6df4977cdaba9dda5f7 Mon Sep 17 00:00:00 2001 From: Ian Wrzesinski Date: Sun, 9 Nov 2025 14:35:33 -0500 Subject: [PATCH 101/105] Split `Space` syntax kind into `SpaceWithNewline` and `SpaceNoNewline` --- crates/typst-eval/src/markup.rs | 6 ++- crates/typst-ide/src/docs.rs | 7 ++- crates/typst-library/src/text/space.rs | 23 ++++++++-- crates/typst-syntax/src/ast.rs | 60 +++++++++++++++++++++----- crates/typst-syntax/src/highlight.rs | 3 +- crates/typst-syntax/src/kind.rs | 30 +++++++++---- crates/typst-syntax/src/lexer.rs | 26 +++-------- crates/typst-syntax/src/node.rs | 6 +-- crates/typst-syntax/src/parser.rs | 6 +-- crates/typst-syntax/src/reparser.rs | 7 +-- tests/suite/text/space.typ | 21 +++++++++ 11 files changed, 139 insertions(+), 56 deletions(-) diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index 0eb09e33728b..8b734caacf14 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -98,7 +98,11 @@ impl Eval for ast::Space<'_> { type Output = Content; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(SpaceElem::shared().clone()) + if self.had_newline() { + Ok(SpaceElem::shared_with_newline().clone()) + } else { + Ok(SpaceElem::shared().clone()) + } } } diff --git a/crates/typst-ide/src/docs.rs b/crates/typst-ide/src/docs.rs index 726795144e36..5b589ec1e40f 100644 --- a/crates/typst-ide/src/docs.rs +++ b/crates/typst-ide/src/docs.rs @@ -76,7 +76,12 @@ impl Docs { lines.push(text.strip_prefix('/').unwrap_or(text)); } else if let Some(comment) = prev.get().cast::() { lines.push(comment.text()); - } else if !matches!(prev.kind(), SyntaxKind::Space | SyntaxKind::Hash) { + } else if !matches!( + prev.kind(), + SyntaxKind::SpaceNoNewline + | SyntaxKind::SpaceWithNewline + | SyntaxKind::Hash + ) { break; } current = prev; diff --git a/crates/typst-library/src/text/space.rs b/crates/typst-library/src/text/space.rs index 143046fac8f7..ab718d1f3089 100644 --- a/crates/typst-library/src/text/space.rs +++ b/crates/typst-library/src/text/space.rs @@ -6,13 +6,30 @@ use crate::foundations::{ }; /// A text space. -#[elem(Unlabellable, PlainText, Repr)] -pub struct SpaceElem {} +#[elem(Unlabellable, PlainText, Repr, PartialEq)] +pub struct SpaceElem { + #[parse(false)] + #[required] + #[internal] + pub had_newline: bool, +} + +impl PartialEq for SpaceElem { + fn eq(&self, _other: &Self) -> bool { + // Note: This is fine for comemo because it only compares based on hash. + true + } +} impl SpaceElem { /// Get the globally shared space element. pub fn shared() -> &'static Content { - singleton!(Content, SpaceElem::new().pack()) + singleton!(Content, SpaceElem::new(false).pack()) + } + + /// A globally shared space element with `had_newline` set to true. + pub fn shared_with_newline() -> &'static Content { + singleton!(Content, SpaceElem::new(true).pack()) } } diff --git a/crates/typst-syntax/src/ast.rs b/crates/typst-syntax/src/ast.rs index 8ed7c3aa2c83..b1b73fe540c6 100644 --- a/crates/typst-syntax/src/ast.rs +++ b/crates/typst-syntax/src/ast.rs @@ -237,7 +237,11 @@ impl<'a> Markup<'a> { .filter(move |node| { // Ignore newline directly after statements without semicolons. let kind = node.kind(); - let keep = !was_stmt || node.kind() != SyntaxKind::Space; + let is_space = matches!( + node.kind(), + SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline + ); + let keep = !was_stmt || !is_space; was_stmt = kind.is_stmt(); keep }) @@ -255,7 +259,8 @@ pub enum Expr<'a> { Space(Space<'a>), /// A forced line break: `\`. Linebreak(Linebreak<'a>), - /// A paragraph break, indicated by one or multiple blank lines. + /// A paragraph break, indicated by contiguous whitespace containing at + /// least two newlines. Only produced in Markup. Parbreak(Parbreak<'a>), /// An escape sequence: `\#`, `\u{1F5FA}`. Escape(Escape<'a>), @@ -377,7 +382,9 @@ pub enum Expr<'a> { impl<'a> Expr<'a> { fn cast_with_space(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Space => Some(Self::Space(Space(node))), + SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline => { + Some(Self::Space(Space(node))) + } _ => Self::from_untyped(node), } } @@ -386,7 +393,7 @@ impl<'a> Expr<'a> { impl<'a> AstNode<'a> for Expr<'a> { fn from_untyped(node: &'a SyntaxNode) -> Option { match node.kind() { - SyntaxKind::Space => Option::None, // Skipped unless using `cast_with_space`. + // Spaces are skipped unless using `cast_with_space`. SyntaxKind::Linebreak => Some(Self::Linebreak(Linebreak(node))), SyntaxKind::Parbreak => Some(Self::Parbreak(Parbreak(node))), SyntaxKind::Text => Some(Self::Text(Text(node))), @@ -590,10 +597,35 @@ impl<'a> Text<'a> { } } -node! { - /// Whitespace in markup or math. Has at most one newline in markup, as more - /// indicate a paragraph break. - struct Space +/// Whitespace in markup or math. Has at most one newline in markup, as more +/// indicate a paragraph break. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Space<'a>(&'a SyntaxNode); + +impl Space<'_> { + /// Whether the Space contains a newline. + pub fn had_newline(self) -> bool { + self.0.kind() == SyntaxKind::SpaceWithNewline + } +} + +impl<'a> AstNode<'a> for Space<'a> { + fn from_untyped(node: &'a SyntaxNode) -> Option { + match node.kind() { + SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline => Some(Self(node)), + _ => Option::None, + } + } + + fn to_untyped(self) -> &'a SyntaxNode { + self.0 + } + + fn placeholder() -> Self { + static PLACEHOLDER: SyntaxNode = + SyntaxNode::placeholder(SyntaxKind::SpaceNoNewline); + Self(&PLACEHOLDER) + } } node! { @@ -602,7 +634,8 @@ node! { } node! { - /// A paragraph break, indicated by one or multiple blank lines. + /// A paragraph break, indicated by contiguous whitespace containing at + /// least two newlines. Only produced in Markup. struct Parbreak } @@ -873,7 +906,10 @@ impl<'a> Equation<'a> { /// Whether the equation should be displayed as a separate block. pub fn block(self) -> bool { let is_space = |node: Option<&SyntaxNode>| { - node.map(SyntaxNode::kind) == Some(SyntaxKind::Space) + matches!( + node.map(SyntaxNode::kind), + Some(SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline) + ) }; is_space(self.0.children().nth(1)) && is_space(self.0.children().nth_back(1)) } @@ -1152,7 +1188,9 @@ impl<'a> MathArgs<'a> { let semicolon_ends_code = prev_hash; prev_hash = false; let item = match node.kind() { - SyntaxKind::Space => MathArgItem::Space(Space(node)), + SyntaxKind::SpaceNoNewline | SyntaxKind::SpaceWithNewline => { + MathArgItem::Space(Space(node)) + } SyntaxKind::Comma => MathArgItem::Comma(',', node), SyntaxKind::LeftParen => MathArgItem::LeftParen('(', node), SyntaxKind::RightParen => MathArgItem::RightParen(')', node), diff --git a/crates/typst-syntax/src/highlight.rs b/crates/typst-syntax/src/highlight.rs index 88077582684f..37d67a39d7df 100644 --- a/crates/typst-syntax/src/highlight.rs +++ b/crates/typst-syntax/src/highlight.rs @@ -149,7 +149,8 @@ pub fn highlight(node: &LinkedNode) -> Option { } SyntaxKind::Markup => None, SyntaxKind::Text => None, - SyntaxKind::Space => None, + SyntaxKind::SpaceNoNewline => None, + SyntaxKind::SpaceWithNewline => None, SyntaxKind::Linebreak => Some(Tag::Escape), SyntaxKind::Parbreak => None, SyntaxKind::Escape => Some(Tag::Escape), diff --git a/crates/typst-syntax/src/kind.rs b/crates/typst-syntax/src/kind.rs index 93fe0c700705..73b41a891d4a 100644 --- a/crates/typst-syntax/src/kind.rs +++ b/crates/typst-syntax/src/kind.rs @@ -22,12 +22,15 @@ pub enum SyntaxKind { Markup, /// Plain text without markup. Text, - /// Whitespace. Contains at most one newline in markup, as more indicate a - /// paragraph break. - Space, + /// Whitespace with no newlines. + SpaceNoNewline, + /// Whitespace with _exactly one_ newline in Markup (more would be a + /// `ParBreak`) and _at least one_ newline in Code/Math. + SpaceWithNewline, /// A forced line break: `\`. Linebreak, - /// A paragraph break, indicated by one or multiple blank lines. + /// A paragraph break, indicated by contiguous whitespace containing at + /// least two newlines. Only produced in Markup. Parbreak, /// An escape sequence: `\#`, `\u{1F5FA}`. Escape, @@ -372,7 +375,8 @@ impl SyntaxKind { Self::Shebang | Self::LineComment | Self::BlockComment - | Self::Space + | Self::SpaceNoNewline + | Self::SpaceWithNewline | Self::Parbreak ) } @@ -392,7 +396,8 @@ impl SyntaxKind { Self::BlockComment => "block comment", Self::Markup => "markup", Self::Text => "text", - Self::Space => "space", + Self::SpaceNoNewline => "space", + Self::SpaceWithNewline => "space", Self::Linebreak => "line break", Self::Parbreak => "paragraph break", Self::Escape => "escape sequence", @@ -586,9 +591,10 @@ impl SyntaxKind { Self::BlockComment => None, // none Self::Markup => Known(Markup), - Self::Text => Text, // none: Raw | markup: expr - Self::Space => Space, // code/math: part | markup: expr/part - Self::Linebreak => Parent, // math/markup: expr + Self::Text => Text, // none: Raw | markup: expr + Self::SpaceNoNewline => Space, // code: part | markup/math: expr/part + Self::SpaceWithNewline => Space, // code: part | markup/math: expr/part + Self::Linebreak => Parent, // math/markup: expr Self::Parbreak => Known(Markup), Self::Escape => Parent, // math/markup: expr Self::Shorthand => Known(Markup), @@ -849,5 +855,11 @@ mod test { test_mode("$ #$x$; $", [2, 5], Some(Code)); test_mode("#[$x$]", [2, 3], Some(Math)); test_mode("#[$x$]", [1, 4], Some(Markup)); + + // Single newlines at the edges of modes + // (since the other tests mostly use normal spaces instead of newlines) + test_mode("#[\n$\n#{\nx\n}\n$\n]", [2, 13], Some(Markup)); + test_mode("#[\n$\n#{\nx\n}\n$\n]", [4, 11], Some(Math)); + test_mode("#[\n$\n#{\nx\n}\n$\n]", [7, 9], Some(Code)); } } diff --git a/crates/typst-syntax/src/lexer.rs b/crates/typst-syntax/src/lexer.rs index 43f69831ef55..43b269c64c12 100644 --- a/crates/typst-syntax/src/lexer.rs +++ b/crates/typst-syntax/src/lexer.rs @@ -19,23 +19,15 @@ pub(super) struct Lexer<'s> { /// The mode the lexer is in. This determines which kinds of tokens it /// produces. mode: SyntaxMode, - /// Whether the last token contained a newline. - newline: bool, /// An error plus hints for the current token being produced. This is always /// `None` between calls to [`Lexer::next`]. error: Option<(EcoString, EcoVec)>, } impl<'s> Lexer<'s> { - /// Create a new lexer with the given mode and a prefix to offset column - /// calculations. + /// Create a new lexer with the given mode. pub fn new(text: &'s str, mode: SyntaxMode) -> Self { - Self { - s: Scanner::new(text), - mode, - newline: false, - error: None, - } + Self { s: Scanner::new(text), mode, error: None } } /// Get the current lexing mode. @@ -59,11 +51,6 @@ impl<'s> Lexer<'s> { self.s.jump(index); } - /// Whether the last token contained a newline. - pub fn newline(&self) -> bool { - self.newline - } - /// The number of characters until the most recent newline from an index. pub fn column(&self, index: usize) -> usize { let mut s = self.s; // Make a new temporary scanner (cheap). @@ -96,7 +83,6 @@ impl Lexer<'_> { debug_assert!(self.error.is_none()); let start = self.s.cursor(); - self.newline = false; let kind = match self.s.eat() { Some(c) if is_space(c, self.mode) => self.whitespace(start, c), Some('#') if start == 0 && self.s.eat_if('!') => self.shebang(), @@ -140,11 +126,12 @@ impl Lexer<'_> { _ => count_newlines(self.s.from(start)), }; - self.newline = newlines > 0; if self.mode == SyntaxMode::Markup && newlines >= 2 { SyntaxKind::Parbreak + } else if newlines > 0 { + SyntaxKind::SpaceWithNewline } else { - SyntaxKind::Space + SyntaxKind::SpaceNoNewline } } @@ -1130,7 +1117,8 @@ impl ScannerExt for Scanner<'_> { } } -/// Whether a character will become a [`SyntaxKind::Space`] token. +/// Whether a character will become a space token, either +/// [`SyntaxKind::SpaceNoNewline`] or [`SyntaxKind::SpaceWithNewline`]. #[inline] fn is_space(character: char, mode: SyntaxMode) -> bool { match mode { diff --git a/crates/typst-syntax/src/node.rs b/crates/typst-syntax/src/node.rs index 08a3de889b45..79526cb0fce4 100644 --- a/crates/typst-syntax/src/node.rs +++ b/crates/typst-syntax/src/node.rs @@ -1493,12 +1493,12 @@ mod tests { Markup: 14 [ Heading: 6 [ HeadingMarker: \"=\", - Space: \" \", + SpaceNoNewline: \" \", Markup: 4 [ Text: \"Head\", ], ], - Space: \" \", + SpaceNoNewline: \" \", Label: \"