From f9897479d2a8a865c4033bc44ec9a85fb5000795 Mon Sep 17 00:00:00 2001 From: cAttte <26514199+cAttte@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:09:37 -0300 Subject: [PATCH 001/101] Unify `EvalMode` and `LexMode` into `SyntaxMode` (#6432) --- crates/typst-cli/src/query.rs | 6 +-- crates/typst-eval/src/lib.rs | 17 ++++---- crates/typst-library/src/foundations/cast.rs | 17 +++++++- crates/typst-library/src/foundations/mod.rs | 6 +-- .../typst-library/src/model/bibliography.rs | 6 +-- crates/typst-library/src/routines.rs | 15 +------ crates/typst-syntax/src/lexer.rs | 37 ++++++---------- crates/typst-syntax/src/lib.rs | 13 +++++- crates/typst-syntax/src/parser.rs | 43 ++++++++++--------- 9 files changed, 82 insertions(+), 78 deletions(-) diff --git a/crates/typst-cli/src/query.rs b/crates/typst-cli/src/query.rs index 7806e456f..b1a446203 100644 --- a/crates/typst-cli/src/query.rs +++ b/crates/typst-cli/src/query.rs @@ -5,9 +5,9 @@ use typst::diag::{bail, HintedStrResult, StrResult, Warned}; use typst::engine::Sink; use typst::foundations::{Content, IntoValue, LocatableSelector, Scope}; use typst::layout::PagedDocument; -use typst::syntax::Span; +use typst::syntax::{Span, SyntaxMode}; use typst::World; -use typst_eval::{eval_string, EvalMode}; +use typst_eval::eval_string; use crate::args::{QueryCommand, SerializationFormat}; use crate::compile::print_diagnostics; @@ -63,7 +63,7 @@ fn retrieve( Sink::new().track_mut(), &command.selector, Span::detached(), - EvalMode::Code, + SyntaxMode::Code, Scope::default(), ) .map_err(|errors| { diff --git a/crates/typst-eval/src/lib.rs b/crates/typst-eval/src/lib.rs index 586da26be..e4bbe4f0f 100644 --- a/crates/typst-eval/src/lib.rs +++ b/crates/typst-eval/src/lib.rs @@ -18,7 +18,6 @@ pub use self::call::{eval_closure, CapturesVisitor}; pub use self::flow::FlowEvent; pub use self::import::import; pub use self::vm::Vm; -pub use typst_library::routines::EvalMode; use self::access::*; use self::binding::*; @@ -32,7 +31,7 @@ use typst_library::introspection::Introspector; use typst_library::math::EquationElem; use typst_library::routines::Routines; use typst_library::World; -use typst_syntax::{ast, parse, parse_code, parse_math, Source, Span}; +use typst_syntax::{ast, parse, parse_code, parse_math, Source, Span, SyntaxMode}; /// Evaluate a source file and return the resulting module. #[comemo::memoize] @@ -104,13 +103,13 @@ pub fn eval_string( sink: TrackedMut, string: &str, span: Span, - mode: EvalMode, + mode: SyntaxMode, scope: Scope, ) -> SourceResult { let mut root = match mode { - EvalMode::Code => parse_code(string), - EvalMode::Markup => parse(string), - EvalMode::Math => parse_math(string), + SyntaxMode::Code => parse_code(string), + SyntaxMode::Markup => parse(string), + SyntaxMode::Math => parse_math(string), }; root.synthesize(span); @@ -141,11 +140,11 @@ pub fn eval_string( // Evaluate the code. let output = match mode { - EvalMode::Code => root.cast::().unwrap().eval(&mut vm)?, - EvalMode::Markup => { + SyntaxMode::Code => root.cast::().unwrap().eval(&mut vm)?, + SyntaxMode::Markup => { Value::Content(root.cast::().unwrap().eval(&mut vm)?) } - EvalMode::Math => Value::Content( + SyntaxMode::Math => Value::Content( EquationElem::new(root.cast::().unwrap().eval(&mut vm)?) .with_block(false) .pack() diff --git a/crates/typst-library/src/foundations/cast.rs b/crates/typst-library/src/foundations/cast.rs index 73645491f..5e0ba688e 100644 --- a/crates/typst-library/src/foundations/cast.rs +++ b/crates/typst-library/src/foundations/cast.rs @@ -9,7 +9,7 @@ use std::ops::Add; use ecow::eco_format; use smallvec::SmallVec; -use typst_syntax::{Span, Spanned}; +use typst_syntax::{Span, Spanned, SyntaxMode}; use unicode_math_class::MathClass; use crate::diag::{At, HintedStrResult, HintedString, SourceResult, StrResult}; @@ -459,6 +459,21 @@ impl FromValue for Never { } } +cast! { + SyntaxMode, + self => IntoValue::into_value(match self { + SyntaxMode::Markup => "markup", + SyntaxMode::Math => "math", + SyntaxMode::Code => "code", + }), + /// Evaluate as markup, as in a Typst file. + "markup" => SyntaxMode::Markup, + /// Evaluate as math, as in an equation. + "math" => SyntaxMode::Math, + /// Evaluate as code, as after a hash. + "code" => SyntaxMode::Code, +} + cast! { MathClass, self => IntoValue::into_value(match self { diff --git a/crates/typst-library/src/foundations/mod.rs b/crates/typst-library/src/foundations/mod.rs index d42be15b1..6840f855d 100644 --- a/crates/typst-library/src/foundations/mod.rs +++ b/crates/typst-library/src/foundations/mod.rs @@ -69,6 +69,7 @@ pub use self::ty::*; pub use self::value::*; pub use self::version::*; pub use typst_macros::{scope, ty}; +use typst_syntax::SyntaxMode; #[rustfmt::skip] #[doc(hidden)] @@ -83,7 +84,6 @@ use typst_syntax::Spanned; use crate::diag::{bail, SourceResult, StrResult}; use crate::engine::Engine; -use crate::routines::EvalMode; use crate::{Feature, Features}; /// Hook up all `foundations` definitions. @@ -273,8 +273,8 @@ pub fn eval( /// #eval("1_2^3", mode: "math") /// ``` #[named] - #[default(EvalMode::Code)] - mode: EvalMode, + #[default(SyntaxMode::Code)] + mode: SyntaxMode, /// A scope of definitions that are made available. /// /// ```example diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index a85efc810..7bfacfc66 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -16,7 +16,7 @@ use hayagriva::{ }; use indexmap::IndexMap; use smallvec::{smallvec, SmallVec}; -use typst_syntax::{Span, Spanned}; +use typst_syntax::{Span, Spanned, SyntaxMode}; use typst_utils::{Get, ManuallyHash, NonZeroExt, PicoStr}; use crate::diag::{ @@ -39,7 +39,7 @@ use crate::model::{ CitationForm, CiteGroup, Destination, FootnoteElem, HeadingElem, LinkElem, ParElem, Url, }; -use crate::routines::{EvalMode, Routines}; +use crate::routines::Routines; use crate::text::{ FontStyle, Lang, LocalName, Region, Smallcaps, SubElem, SuperElem, TextElem, WeightDelta, @@ -1024,7 +1024,7 @@ impl ElemRenderer<'_> { Sink::new().track_mut(), math, self.span, - EvalMode::Math, + SyntaxMode::Math, Scope::new(), ) .map(Value::display) diff --git a/crates/typst-library/src/routines.rs b/crates/typst-library/src/routines.rs index 6f0cb32b1..59ce83282 100644 --- a/crates/typst-library/src/routines.rs +++ b/crates/typst-library/src/routines.rs @@ -4,7 +4,7 @@ use std::hash::{Hash, Hasher}; use std::num::NonZeroUsize; use comemo::{Tracked, TrackedMut}; -use typst_syntax::Span; +use typst_syntax::{Span, SyntaxMode}; use typst_utils::LazyHash; use crate::diag::SourceResult; @@ -58,7 +58,7 @@ routines! { sink: TrackedMut, string: &str, span: Span, - mode: EvalMode, + mode: SyntaxMode, scope: Scope, ) -> SourceResult @@ -312,17 +312,6 @@ routines! { ) -> SourceResult } -/// In which mode to evaluate a string. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Cast)] -pub enum EvalMode { - /// Evaluate as code, as after a hash. - Code, - /// Evaluate as markup, like in a Typst file. - Markup, - /// Evaluate as math, as in an equation. - Math, -} - /// Defines what kind of realization we are performing. pub enum RealizationKind<'a> { /// This the root realization for layout. Requires a mutable reference diff --git a/crates/typst-syntax/src/lexer.rs b/crates/typst-syntax/src/lexer.rs index 7d363d7b5..74f14cfeb 100644 --- a/crates/typst-syntax/src/lexer.rs +++ b/crates/typst-syntax/src/lexer.rs @@ -4,7 +4,7 @@ use unicode_script::{Script, UnicodeScript}; use unicode_segmentation::UnicodeSegmentation; use unscanny::Scanner; -use crate::{SyntaxError, SyntaxKind, SyntaxNode}; +use crate::{SyntaxError, SyntaxKind, SyntaxMode, SyntaxNode}; /// An iterator over a source code string which returns tokens. #[derive(Clone)] @@ -13,28 +13,17 @@ pub(super) struct Lexer<'s> { s: Scanner<'s>, /// The mode the lexer is in. This determines which kinds of tokens it /// produces. - mode: LexMode, + mode: SyntaxMode, /// Whether the last token contained a newline. newline: bool, /// An error for the last token. error: Option, } -/// What kind of tokens to emit. -#[derive(Debug, Copy, Clone, Eq, PartialEq)] -pub(super) enum LexMode { - /// Text and markup. - Markup, - /// Math atoms, operators, etc. - Math, - /// Keywords, literals and operators. - Code, -} - impl<'s> Lexer<'s> { /// Create a new lexer with the given mode and a prefix to offset column /// calculations. - pub fn new(text: &'s str, mode: LexMode) -> Self { + pub fn new(text: &'s str, mode: SyntaxMode) -> Self { Self { s: Scanner::new(text), mode, @@ -44,12 +33,12 @@ impl<'s> Lexer<'s> { } /// Get the current lexing mode. - pub fn mode(&self) -> LexMode { + pub fn mode(&self) -> SyntaxMode { self.mode } /// Change the lexing mode. - pub fn set_mode(&mut self, mode: LexMode) { + pub fn set_mode(&mut self, mode: SyntaxMode) { self.mode = mode; } @@ -92,7 +81,7 @@ impl Lexer<'_> { } } -/// Shared methods with all [`LexMode`]. +/// Shared methods with all [`SyntaxMode`]. impl Lexer<'_> { /// Return the next token in our text. Returns both the [`SyntaxNode`] /// and the raw [`SyntaxKind`] to make it more ergonomic to check the kind @@ -114,14 +103,14 @@ impl Lexer<'_> { ); kind } - Some('`') if self.mode != LexMode::Math => return self.raw(), + Some('`') if self.mode != SyntaxMode::Math => return self.raw(), Some(c) => match self.mode { - LexMode::Markup => self.markup(start, c), - LexMode::Math => match self.math(start, c) { + SyntaxMode::Markup => self.markup(start, c), + SyntaxMode::Math => match self.math(start, c) { (kind, None) => kind, (kind, Some(node)) => return (kind, node), }, - LexMode::Code => self.code(start, c), + SyntaxMode::Code => self.code(start, c), }, None => SyntaxKind::End, @@ -145,7 +134,7 @@ impl Lexer<'_> { }; self.newline = newlines > 0; - if self.mode == LexMode::Markup && newlines >= 2 { + if self.mode == SyntaxMode::Markup && newlines >= 2 { SyntaxKind::Parbreak } else { SyntaxKind::Space @@ -965,9 +954,9 @@ impl ScannerExt for Scanner<'_> { /// Whether a character will become a [`SyntaxKind::Space`] token. #[inline] -fn is_space(character: char, mode: LexMode) -> bool { +fn is_space(character: char, mode: SyntaxMode) -> bool { match mode { - LexMode::Markup => matches!(character, ' ' | '\t') || is_newline(character), + SyntaxMode::Markup => matches!(character, ' ' | '\t') || is_newline(character), _ => character.is_whitespace(), } } diff --git a/crates/typst-syntax/src/lib.rs b/crates/typst-syntax/src/lib.rs index 1249f88e9..4741506c5 100644 --- a/crates/typst-syntax/src/lib.rs +++ b/crates/typst-syntax/src/lib.rs @@ -30,5 +30,16 @@ pub use self::path::VirtualPath; pub use self::source::Source; pub use self::span::{Span, Spanned}; -use self::lexer::{LexMode, Lexer}; +use self::lexer::Lexer; use self::parser::{reparse_block, reparse_markup}; + +/// The syntax mode of a portion of Typst code. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum SyntaxMode { + /// Text and markup, as in the top level. + Markup, + /// Math atoms, operators, etc., as in equations. + Math, + /// Keywords, literals and operators, as after hashes. + Code, +} diff --git a/crates/typst-syntax/src/parser.rs b/crates/typst-syntax/src/parser.rs index a68815806..b452c2c09 100644 --- a/crates/typst-syntax/src/parser.rs +++ b/crates/typst-syntax/src/parser.rs @@ -7,12 +7,12 @@ use typst_utils::default_math_class; use unicode_math_class::MathClass; use crate::set::{syntax_set, SyntaxSet}; -use crate::{ast, set, LexMode, Lexer, SyntaxError, SyntaxKind, SyntaxNode}; +use crate::{ast, set, Lexer, SyntaxError, SyntaxKind, SyntaxMode, SyntaxNode}; /// Parses a source file as top-level markup. pub fn parse(text: &str) -> SyntaxNode { let _scope = typst_timing::TimingScope::new("parse"); - let mut p = Parser::new(text, 0, LexMode::Markup); + let mut p = Parser::new(text, 0, SyntaxMode::Markup); markup_exprs(&mut p, true, syntax_set!(End)); p.finish_into(SyntaxKind::Markup) } @@ -20,7 +20,7 @@ pub fn parse(text: &str) -> SyntaxNode { /// Parses top-level code. pub fn parse_code(text: &str) -> SyntaxNode { let _scope = typst_timing::TimingScope::new("parse code"); - let mut p = Parser::new(text, 0, LexMode::Code); + let mut p = Parser::new(text, 0, SyntaxMode::Code); code_exprs(&mut p, syntax_set!(End)); p.finish_into(SyntaxKind::Code) } @@ -28,7 +28,7 @@ pub fn parse_code(text: &str) -> SyntaxNode { /// Parses top-level math. pub fn parse_math(text: &str) -> SyntaxNode { let _scope = typst_timing::TimingScope::new("parse math"); - let mut p = Parser::new(text, 0, LexMode::Math); + let mut p = Parser::new(text, 0, SyntaxMode::Math); math_exprs(&mut p, syntax_set!(End)); p.finish_into(SyntaxKind::Math) } @@ -63,7 +63,7 @@ pub(super) fn reparse_markup( nesting: &mut usize, top_level: bool, ) -> Option> { - let mut p = Parser::new(text, range.start, LexMode::Markup); + let mut p = Parser::new(text, range.start, SyntaxMode::Markup); *at_start |= p.had_newline(); while !p.end() && p.current_start() < range.end { // If not top-level and at a new RightBracket, stop the reparse. @@ -205,7 +205,7 @@ fn reference(p: &mut Parser) { /// Parses a mathematical equation: `$x$`, `$ x^2 $`. fn equation(p: &mut Parser) { let m = p.marker(); - p.enter_modes(LexMode::Math, AtNewline::Continue, |p| { + p.enter_modes(SyntaxMode::Math, AtNewline::Continue, |p| { p.assert(SyntaxKind::Dollar); math(p, syntax_set!(Dollar, End)); p.expect_closing_delimiter(m, SyntaxKind::Dollar); @@ -615,7 +615,7 @@ fn code_exprs(p: &mut Parser, stop_set: SyntaxSet) { /// Parses an atomic code expression embedded in markup or math. fn embedded_code_expr(p: &mut Parser) { - p.enter_modes(LexMode::Code, AtNewline::Stop, |p| { + p.enter_modes(SyntaxMode::Code, AtNewline::Stop, |p| { p.assert(SyntaxKind::Hash); if p.had_trivia() || p.end() { p.expected("expression"); @@ -777,7 +777,7 @@ fn code_primary(p: &mut Parser, atomic: bool) { /// Reparses a full content or code block. pub(super) fn reparse_block(text: &str, range: Range) -> Option { - let mut p = Parser::new(text, range.start, LexMode::Code); + let mut p = Parser::new(text, range.start, SyntaxMode::Code); assert!(p.at(SyntaxKind::LeftBracket) || p.at(SyntaxKind::LeftBrace)); block(&mut p); (p.balanced && p.prev_end() == range.end) @@ -796,7 +796,7 @@ fn block(p: &mut Parser) { /// Parses a code block: `{ let x = 1; x + 2 }`. fn code_block(p: &mut Parser) { let m = p.marker(); - p.enter_modes(LexMode::Code, AtNewline::Continue, |p| { + p.enter_modes(SyntaxMode::Code, AtNewline::Continue, |p| { p.assert(SyntaxKind::LeftBrace); code(p, syntax_set!(RightBrace, RightBracket, RightParen, End)); p.expect_closing_delimiter(m, SyntaxKind::RightBrace); @@ -807,7 +807,7 @@ fn code_block(p: &mut Parser) { /// Parses a content block: `[*Hi* there!]`. fn content_block(p: &mut Parser) { let m = p.marker(); - p.enter_modes(LexMode::Markup, AtNewline::Continue, |p| { + p.enter_modes(SyntaxMode::Markup, AtNewline::Continue, |p| { p.assert(SyntaxKind::LeftBracket); markup(p, true, true, syntax_set!(RightBracket, End)); p.expect_closing_delimiter(m, SyntaxKind::RightBracket); @@ -1516,10 +1516,10 @@ fn pattern_leaf<'s>( /// ### Modes /// /// The parser manages the transitions between the three modes of Typst through -/// [lexer modes](`LexMode`) and [newline modes](`AtNewline`). +/// [syntax modes](`SyntaxMode`) and [newline modes](`AtNewline`). /// -/// The lexer modes map to the three Typst modes and are stored in the lexer, -/// changing which`SyntaxKind`s it will generate. +/// The syntax modes map to the three Typst modes and are stored in the lexer, +/// changing which `SyntaxKind`s it will generate. /// /// The newline mode is used to determine whether a newline should end the /// current expression. If so, the parser temporarily changes `token`'s kind to @@ -1529,7 +1529,7 @@ struct Parser<'s> { /// The source text shared with the lexer. text: &'s str, /// A lexer over the source text with multiple modes. Defines the boundaries - /// of tokens and determines their [`SyntaxKind`]. Contains the [`LexMode`] + /// of tokens and determines their [`SyntaxKind`]. Contains the [`SyntaxMode`] /// defining our current Typst mode. lexer: Lexer<'s>, /// The newline mode: whether to insert a temporary end at newlines. @@ -1612,7 +1612,7 @@ impl AtNewline { AtNewline::RequireColumn(min_col) => { // When the column is `None`, the newline doesn't start a // column, and we continue parsing. This may happen on the - // boundary of lexer modes, since we only report a column in + // boundary of syntax modes, since we only report a column in // Markup. column.is_some_and(|column| column <= min_col) } @@ -1643,8 +1643,8 @@ impl IndexMut for Parser<'_> { /// Creating/Consuming the parser and getting info about the current token. impl<'s> Parser<'s> { - /// Create a new parser starting from the given text offset and lexer mode. - fn new(text: &'s str, offset: usize, mode: LexMode) -> Self { + /// Create a new parser starting from the given text offset and syntax mode. + fn new(text: &'s str, offset: usize, mode: SyntaxMode) -> Self { let mut lexer = Lexer::new(text, mode); lexer.jump(offset); let nl_mode = AtNewline::Continue; @@ -1825,13 +1825,13 @@ impl<'s> Parser<'s> { self.nodes.insert(from, SyntaxNode::inner(kind, children)); } - /// Parse within the [`LexMode`] for subsequent tokens (does not change the + /// Parse within the [`SyntaxMode`] for subsequent tokens (does not change the /// current token). This may re-lex the final token on exit. /// /// This function effectively repurposes the call stack as a stack of modes. fn enter_modes( &mut self, - mode: LexMode, + mode: SyntaxMode, stop: AtNewline, func: impl FnOnce(&mut Parser<'s>), ) { @@ -1891,7 +1891,8 @@ impl<'s> Parser<'s> { } let newline = if had_newline { - let column = (lexer.mode() == LexMode::Markup).then(|| lexer.column(start)); + let column = + (lexer.mode() == SyntaxMode::Markup).then(|| lexer.column(start)); let newline = Newline { column, parbreak }; if nl_mode.stop_at(newline, kind) { // Insert a temporary `SyntaxKind::End` to halt the parser. @@ -1938,7 +1939,7 @@ struct Checkpoint { #[derive(Clone)] struct PartialState { cursor: usize, - lex_mode: LexMode, + lex_mode: SyntaxMode, token: Token, } From 4a638f41cde37312390359a5345073bed5835ae6 Mon Sep 17 00:00:00 2001 From: cAttte <26514199+cAttte@users.noreply.github.com> Date: Thu, 12 Jun 2025 11:10:04 -0300 Subject: [PATCH 002/101] Consume `data` argument in `pdf.embed()` (#6435) --- crates/typst-library/src/pdf/embed.rs | 2 +- tests/suite/pdf/embed.typ | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/pdf/embed.rs b/crates/typst-library/src/pdf/embed.rs index f902e7f14..4c01cd651 100644 --- a/crates/typst-library/src/pdf/embed.rs +++ b/crates/typst-library/src/pdf/embed.rs @@ -59,7 +59,7 @@ pub struct EmbedElem { // We can't distinguish between the two at the moment. #[required] #[parse( - match args.find::()? { + match args.eat::()? { Some(data) => data, None => engine.world.file(id).at(span)?, } diff --git a/tests/suite/pdf/embed.typ b/tests/suite/pdf/embed.typ index 83f006d63..4546532b7 100644 --- a/tests/suite/pdf/embed.typ +++ b/tests/suite/pdf/embed.typ @@ -28,3 +28,7 @@ mime-type: "text/plain", description: "A test file", ) + +--- pdf-embed-invalid-data --- +// Error: 38-45 expected bytes, found string +#pdf.embed("/assets/text/hello.txt", "hello") From 64d0a564bf92b6540955d820149e62e7fab394c5 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 12 Jun 2025 16:11:18 +0200 Subject: [PATCH 003/101] Better error message for compile time string interning failure (#6439) --- crates/typst-utils/src/pico.rs | 45 +++++++++++++++++++++++++++------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/crates/typst-utils/src/pico.rs b/crates/typst-utils/src/pico.rs index ce43667e9..3aa4570f2 100644 --- a/crates/typst-utils/src/pico.rs +++ b/crates/typst-utils/src/pico.rs @@ -72,7 +72,7 @@ impl PicoStr { pub const fn constant(string: &'static str) -> PicoStr { match PicoStr::try_constant(string) { Ok(value) => value, - Err(err) => panic!("{}", err.message()), + Err(err) => failed_to_compile_time_intern(err, string), } } @@ -190,15 +190,9 @@ mod bitcode { impl EncodingError { pub const fn message(&self) -> &'static str { match self { - Self::TooLong => { - "the maximum auto-internible string length is 12. \ - you can add an exception to typst-utils/src/pico.rs \ - to intern longer strings." - } + Self::TooLong => "the maximum auto-internible string length is 12", Self::BadChar => { - "can only auto-intern the chars 'a'-'z', '1'-'4', and '-'. \ - you can add an exception to typst-utils/src/pico.rs \ - to intern other strings." + "can only auto-intern the chars 'a'-'z', '1'-'4', and '-'" } } } @@ -356,6 +350,39 @@ impl Hash for ResolvedPicoStr { } } +/// The error when a string could not be interned at compile time. Because the +/// normal formatting machinery is not available at compile time, just producing +/// the message is a bit involved ... +#[track_caller] +const fn failed_to_compile_time_intern( + error: bitcode::EncodingError, + string: &'static str, +) -> ! { + const CAPACITY: usize = 512; + const fn push((buf, i): &mut ([u8; CAPACITY], usize), s: &str) { + let mut k = 0; + while k < s.len() && *i < buf.len() { + buf[*i] = s.as_bytes()[k]; + k += 1; + *i += 1; + } + } + + let mut dest = ([0; CAPACITY], 0); + push(&mut dest, "failed to compile-time intern string \""); + push(&mut dest, string); + push(&mut dest, "\". "); + push(&mut dest, error.message()); + push(&mut dest, ". you can add an exception to "); + push(&mut dest, file!()); + push(&mut dest, " to intern longer strings."); + + let (slice, _) = dest.0.split_at(dest.1); + let Ok(message) = std::str::from_utf8(slice) else { panic!() }; + + panic!("{}", message); +} + #[cfg(test)] mod tests { use super::*; From f32cd5b3e1e9b5c81f8fe72042212a7c7d3a43a7 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 19 Jun 2025 09:29:35 +0200 Subject: [PATCH 004/101] Ensure that label repr is syntactically valid (#6456) --- crates/typst-library/src/foundations/label.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/typst-library/src/foundations/label.rs b/crates/typst-library/src/foundations/label.rs index 2f5520b1c..3b9b010c5 100644 --- a/crates/typst-library/src/foundations/label.rs +++ b/crates/typst-library/src/foundations/label.rs @@ -79,7 +79,12 @@ impl Label { impl Repr for Label { fn repr(&self) -> EcoString { - eco_format!("<{}>", self.resolve()) + let resolved = self.resolve(); + if typst_syntax::is_valid_label_literal_id(&resolved) { + eco_format!("<{resolved}>") + } else { + eco_format!("label({})", resolved.repr()) + } } } From 0bc68df2a9a87ca7e36e34dab56b07c666d64760 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 19 Jun 2025 09:29:38 +0200 Subject: [PATCH 005/101] Hint for label in both document and bibliography (#6457) --- crates/typst-library/src/model/reference.rs | 13 ++++++++++--- tests/suite/model/ref.typ | 3 ++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index 7d44cccc0..f22d70b32 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -5,7 +5,7 @@ use crate::diag::{bail, At, Hint, SourceResult}; use crate::engine::Engine; use crate::foundations::{ cast, elem, Cast, Content, Context, Func, IntoValue, Label, NativeElement, Packed, - Show, Smart, StyleChain, Synthesize, + Repr, Show, Smart, StyleChain, Synthesize, }; use crate::introspection::{Counter, CounterKey, Locatable}; use crate::math::EquationElem; @@ -229,8 +229,15 @@ impl Show for Packed { // RefForm::Normal if BibliographyElem::has(engine, self.target) { - if elem.is_ok() { - bail!(span, "label occurs in the document and its bibliography"); + if let Ok(elem) = elem { + bail!( + span, + "label `{}` occurs both in the document and its bibliography", + self.target.repr(); + hint: "change either the {}'s label or the \ + bibliography key to resolve the ambiguity", + elem.func().name(), + ); } return Ok(to_citation(self, engine, styles)?.pack().spanned(span)); diff --git a/tests/suite/model/ref.typ b/tests/suite/model/ref.typ index 2f8e2fa25..87b1c409a 100644 --- a/tests/suite/model/ref.typ +++ b/tests/suite/model/ref.typ @@ -51,7 +51,8 @@ $ A = 1 $ // Test ambiguous reference. = Introduction -// Error: 1-7 label occurs in the document and its bibliography +// Error: 1-7 label `` occurs both in the document and its bibliography +// Hint: 1-7 change either the heading's label or the bibliography key to resolve the ambiguity @arrgh #bibliography("/assets/bib/works.bib") From 4588595792cec196298446c47c99c35e323b663e Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Thu, 19 Jun 2025 22:20:15 +0300 Subject: [PATCH 006/101] Prefer `.yaml` over `.yml` in the docs (#6436) --- crates/typst-library/src/model/bibliography.rs | 4 ++-- docs/changelog/0.9.0.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 7bfacfc66..8056d4ab3 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -90,7 +90,7 @@ use crate::World; /// ``` #[elem(Locatable, Synthesize, Show, ShowSet, LocalName)] pub struct BibliographyElem { - /// One or multiple paths to or raw bytes for Hayagriva `.yml` and/or + /// One or multiple paths to or raw bytes for Hayagriva `.yaml` and/or /// BibLaTeX `.bib` files. /// /// This can be a: @@ -385,7 +385,7 @@ fn decode_library(loaded: &Loaded) -> SourceResult { .within(loaded), _ => bail!( loaded.source.span, - "unknown bibliography format (must be .yml/.yaml or .bib)" + "unknown bibliography format (must be .yaml/.yml or .bib)" ), } } else { diff --git a/docs/changelog/0.9.0.md b/docs/changelog/0.9.0.md index 0cf3c1bd8..0a4ac8268 100644 --- a/docs/changelog/0.9.0.md +++ b/docs/changelog/0.9.0.md @@ -21,7 +21,7 @@ description: Changes in Typst 0.9.0 - Added [`full`]($bibliography.full) argument to bibliography function to print the full bibliography even if not all works were cited - Bibliography entries can now contain Typst equations (wrapped in `[$..$]` just - like in markup), this works both for `.yml` and `.bib` bibliographies + like in markup), this works both for `.yaml` and `.bib` bibliographies - The hayagriva YAML format was improved. See its [changelog](https://github.com/typst/hayagriva/blob/main/CHANGELOG.md) for more details. **(Breaking change)** From f1c761e88ba50c5560360062c03d7d04f3925c49 Mon Sep 17 00:00:00 2001 From: Noam Zaks <63877260+noamzaks@users.noreply.github.com> Date: Fri, 20 Jun 2025 00:24:02 +0300 Subject: [PATCH 007/101] Fix align link in layout documentation (#6451) --- crates/typst-library/src/layout/align.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/typst-library/src/layout/align.rs b/crates/typst-library/src/layout/align.rs index 5604d6831..0a978dbae 100644 --- a/crates/typst-library/src/layout/align.rs +++ b/crates/typst-library/src/layout/align.rs @@ -104,7 +104,7 @@ impl Show for Packed { } } -/// Where to [align] something along an axis. +/// Where to align something along an axis. /// /// Possible values are: /// - `start`: Aligns at the [start]($direction.start) of the [text From f364b3c3239261e98da4c28c53463b751addfee7 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Fri, 20 Jun 2025 14:32:04 +0200 Subject: [PATCH 008/101] Fix param autocompletion false positive (#6475) --- crates/typst-ide/src/complete.rs | 15 ++++++++++----- crates/typst-ide/src/tests.rs | 3 ++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index c98320679..47727743f 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -701,7 +701,10 @@ fn complete_params(ctx: &mut CompletionContext) -> bool { let mut deciding = ctx.leaf.clone(); while !matches!( deciding.kind(), - SyntaxKind::LeftParen | SyntaxKind::Comma | SyntaxKind::Colon + SyntaxKind::LeftParen + | SyntaxKind::RightParen + | SyntaxKind::Comma + | SyntaxKind::Colon ) { let Some(prev) = deciding.prev_leaf() else { break }; deciding = prev; @@ -1734,6 +1737,8 @@ mod tests { test("#numbering(\"foo\", 1, )", -2) .must_include(["integer"]) .must_exclude(["string"]); + // After argument list no completions. + test("#numbering()", -1).must_exclude(["string"]); } /// Test that autocompletion for values of known type picks up nested @@ -1829,17 +1834,17 @@ mod tests { #[test] fn test_autocomplete_fonts() { - test("#text(font:)", -1) + test("#text(font:)", -2) .must_include(["\"Libertinus Serif\"", "\"New Computer Modern Math\""]); - test("#show link: set text(font: )", -1) + test("#show link: set text(font: )", -2) .must_include(["\"Libertinus Serif\"", "\"New Computer Modern Math\""]); - test("#show math.equation: set text(font: )", -1) + test("#show math.equation: set text(font: )", -2) .must_include(["\"New Computer Modern Math\""]) .must_exclude(["\"Libertinus Serif\""]); - test("#show math.equation: it => { set text(font: )\nit }", -6) + test("#show math.equation: it => { set text(font: )\nit }", -7) .must_include(["\"New Computer Modern Math\""]) .must_exclude(["\"Libertinus Serif\""]); } diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index 1176e4601..5edc05f17 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -202,7 +202,8 @@ impl WorldLike for &str { } } -/// Specifies a position in a file for a test. +/// Specifies a position in a file for a test. Negative numbers index from the +/// back. `-1` is at the very back. pub trait FilePos { fn resolve(self, world: &TestWorld) -> (Source, usize); } From fee6844045e6a898bc65491a8e18cd520b24d08b Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 19 Jun 2025 17:11:13 +0200 Subject: [PATCH 009/101] Encode empty attributes with shorthand syntax --- crates/typst-html/src/encode.rs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 612f923fc..c6a6a7bce 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -69,16 +69,21 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { for (attr, value) in &element.attrs.0 { w.buf.push(' '); w.buf.push_str(&attr.resolve()); - w.buf.push('='); - w.buf.push('"'); - for c in value.chars() { - if charsets::is_valid_in_attribute_value(c) { - w.buf.push(c); - } else { - write_escape(w, c).at(element.span)?; + + // If the string is empty, we can use shorthand syntax. + // `....` + if !value.is_empty() { + w.buf.push('='); + w.buf.push('"'); + for c in value.chars() { + if charsets::is_valid_in_attribute_value(c) { + w.buf.push(c); + } else { + write_escape(w, c).at(element.span)?; + } } + w.buf.push('"'); } - w.buf.push('"'); } w.buf.push('>'); From 3b35f0cecf37c00d33334be6a596a105167875dd Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 19 Jun 2025 17:11:26 +0200 Subject: [PATCH 010/101] Add `Duration::decompose` --- .../typst-library/src/foundations/duration.rs | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/typst-library/src/foundations/duration.rs b/crates/typst-library/src/foundations/duration.rs index 94d44fb2a..90685fa34 100644 --- a/crates/typst-library/src/foundations/duration.rs +++ b/crates/typst-library/src/foundations/duration.rs @@ -16,6 +16,21 @@ impl Duration { pub fn is_zero(&self) -> bool { self.0.is_zero() } + + /// Decomposes the time into whole weeks, days, hours, minutes, and seconds. + pub fn decompose(&self) -> [i64; 5] { + let mut tmp = self.0; + let weeks = tmp.whole_weeks(); + tmp -= weeks.weeks(); + let days = tmp.whole_days(); + tmp -= days.days(); + let hours = tmp.whole_hours(); + tmp -= hours.hours(); + let minutes = tmp.whole_minutes(); + tmp -= minutes.minutes(); + let seconds = tmp.whole_seconds(); + [weeks, days, hours, minutes, seconds] + } } #[scope] @@ -118,34 +133,25 @@ impl Debug for Duration { impl Repr for Duration { fn repr(&self) -> EcoString { - let mut tmp = self.0; + let [weeks, days, hours, minutes, seconds] = self.decompose(); let mut vec = Vec::with_capacity(5); - let weeks = tmp.whole_seconds() / 604_800.0 as i64; if weeks != 0 { vec.push(eco_format!("weeks: {}", weeks.repr())); } - tmp -= weeks.weeks(); - let days = tmp.whole_days(); if days != 0 { vec.push(eco_format!("days: {}", days.repr())); } - tmp -= days.days(); - let hours = tmp.whole_hours(); if hours != 0 { vec.push(eco_format!("hours: {}", hours.repr())); } - tmp -= hours.hours(); - let minutes = tmp.whole_minutes(); if minutes != 0 { vec.push(eco_format!("minutes: {}", minutes.repr())); } - tmp -= minutes.minutes(); - let seconds = tmp.whole_seconds(); if seconds != 0 { vec.push(eco_format!("seconds: {}", seconds.repr())); } From d821633f50f7f4c9edc49b6ac5e88d43802cb206 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 19 Jun 2025 17:20:17 +0200 Subject: [PATCH 011/101] Generic casting for `Axes` --- crates/typst-library/src/layout/axes.rs | 62 +++++++++++++------------ tests/suite/visualize/line.typ | 3 +- tests/suite/visualize/path.typ | 3 +- tests/suite/visualize/polygon.typ | 3 +- 4 files changed, 38 insertions(+), 33 deletions(-) diff --git a/crates/typst-library/src/layout/axes.rs b/crates/typst-library/src/layout/axes.rs index 7a73ba796..e4303f98b 100644 --- a/crates/typst-library/src/layout/axes.rs +++ b/crates/typst-library/src/layout/axes.rs @@ -4,9 +4,12 @@ use std::ops::{BitAnd, BitAndAssign, BitOr, BitOrAssign, Deref, Not}; use typst_utils::Get; -use crate::diag::bail; -use crate::foundations::{array, cast, Array, Resolve, Smart, StyleChain}; -use crate::layout::{Abs, Dir, Length, Ratio, Rel, Size}; +use crate::diag::{bail, HintedStrResult}; +use crate::foundations::{ + array, cast, Array, CastInfo, FromValue, IntoValue, Reflect, Resolve, Smart, + StyleChain, Value, +}; +use crate::layout::{Abs, Dir, Rel, Size}; /// A container with a horizontal and vertical component. #[derive(Default, Copy, Clone, Eq, PartialEq, Hash)] @@ -275,40 +278,39 @@ impl BitAndAssign for Axes { } } -cast! { - Axes>, - self => array![self.x, self.y].into_value(), - array: Array => { - let mut iter = array.into_iter(); - match (iter.next(), iter.next(), iter.next()) { - (Some(a), Some(b), None) => Axes::new(a.cast()?, b.cast()?), - _ => bail!("point array must contain exactly two entries"), - } - }, +impl Reflect for Axes { + fn input() -> CastInfo { + Array::input() + } + + fn output() -> CastInfo { + Array::output() + } + + fn castable(value: &Value) -> bool { + Array::castable(value) + } } -cast! { - Axes, - self => array![self.x, self.y].into_value(), - array: Array => { +impl FromValue for Axes { + fn from_value(value: Value) -> HintedStrResult { + let array = value.cast::()?; let mut iter = array.into_iter(); match (iter.next(), iter.next(), iter.next()) { - (Some(a), Some(b), None) => Axes::new(a.cast()?, b.cast()?), - _ => bail!("ratio array must contain exactly two entries"), + (Some(a), Some(b), None) => Ok(Axes::new(a.cast()?, b.cast()?)), + _ => bail!( + "array must contain exactly two items"; + hint: "the first item determines the value for the X axis \ + and the second item the value for the Y axis" + ), } - }, + } } -cast! { - Axes, - self => array![self.x, self.y].into_value(), - array: Array => { - let mut iter = array.into_iter(); - match (iter.next(), iter.next(), iter.next()) { - (Some(a), Some(b), None) => Axes::new(a.cast()?, b.cast()?), - _ => bail!("length array must contain exactly two entries"), - } - }, +impl IntoValue for Axes { + fn into_value(self) -> Value { + array![self.x.into_value(), self.y.into_value()].into_value() + } } impl Resolve for Axes { diff --git a/tests/suite/visualize/line.typ b/tests/suite/visualize/line.typ index 6cbbbb493..4c763030f 100644 --- a/tests/suite/visualize/line.typ +++ b/tests/suite/visualize/line.typ @@ -84,7 +84,8 @@ --- line-bad-point-array --- // Test errors. -// Error: 12-19 point array must contain exactly two entries +// Error: 12-19 array must contain exactly two items +// Hint: 12-19 the first item determines the value for the X axis and the second item the value for the Y axis #line(end: (50pt,)) --- line-bad-point-component-type --- diff --git a/tests/suite/visualize/path.typ b/tests/suite/visualize/path.typ index e44b2270e..795fde981 100644 --- a/tests/suite/visualize/path.typ +++ b/tests/suite/visualize/path.typ @@ -76,7 +76,8 @@ #path(((0%, 0%), (0%, 0%), (0%, 0%), (0%, 0%))) --- path-bad-point-array --- -// Error: 7-31 point array must contain exactly two entries +// Error: 7-31 array must contain exactly two items +// Hint: 7-31 the first item determines the value for the X axis and the second item the value for the Y axis // Warning: 2-6 the `path` function is deprecated, use `curve` instead #path(((0%, 0%), (0%, 0%, 0%))) diff --git a/tests/suite/visualize/polygon.typ b/tests/suite/visualize/polygon.typ index ec27194df..6cc243d2b 100644 --- a/tests/suite/visualize/polygon.typ +++ b/tests/suite/visualize/polygon.typ @@ -49,7 +49,8 @@ ) --- polygon-bad-point-array --- -// Error: 10-17 point array must contain exactly two entries +// Error: 10-17 array must contain exactly two items +// Hint: 10-17 the first item determines the value for the X axis and the second item the value for the Y axis #polygon((50pt,)) --- polygon-infinite-size --- From 4580daf307cb1ba66458fb46d9442b1183731ec2 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 19 Jun 2025 17:20:45 +0200 Subject: [PATCH 012/101] More type-safe color conversions --- crates/typst-library/src/text/raw.rs | 2 +- crates/typst-library/src/visualize/color.rs | 144 ++++++++++---------- crates/typst-render/src/paint.rs | 4 +- 3 files changed, 73 insertions(+), 77 deletions(-) diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index f2485e16b..e1f4cf13d 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -836,7 +836,7 @@ fn to_typst(synt::Color { r, g, b, a }: synt::Color) -> Color { } fn to_syn(color: Color) -> synt::Color { - let [r, g, b, a] = color.to_rgb().to_vec4_u8(); + let (r, g, b, a) = color.to_rgb().into_format::().into_components(); synt::Color { r, g, b, a } } diff --git a/crates/typst-library/src/visualize/color.rs b/crates/typst-library/src/visualize/color.rs index 24d8305cd..5c657b4e2 100644 --- a/crates/typst-library/src/visualize/color.rs +++ b/crates/typst-library/src/visualize/color.rs @@ -262,7 +262,7 @@ impl Color { color: Color, ) -> SourceResult { Ok(if let Some(color) = args.find::()? { - color.to_luma() + Color::Luma(color.to_luma()) } else { let Component(gray) = args.expect("gray component").unwrap_or(Component(Ratio::one())); @@ -318,7 +318,7 @@ impl Color { color: Color, ) -> SourceResult { Ok(if let Some(color) = args.find::()? { - color.to_oklab() + Color::Oklab(color.to_oklab()) } else { let RatioComponent(l) = args.expect("lightness component")?; let ChromaComponent(a) = args.expect("A component")?; @@ -374,7 +374,7 @@ impl Color { color: Color, ) -> SourceResult { Ok(if let Some(color) = args.find::()? { - color.to_oklch() + Color::Oklch(color.to_oklch()) } else { let RatioComponent(l) = args.expect("lightness component")?; let ChromaComponent(c) = args.expect("chroma component")?; @@ -434,7 +434,7 @@ impl Color { color: Color, ) -> SourceResult { Ok(if let Some(color) = args.find::()? { - color.to_linear_rgb() + Color::LinearRgb(color.to_linear_rgb()) } else { let Component(r) = args.expect("red component")?; let Component(g) = args.expect("green component")?; @@ -505,7 +505,7 @@ impl Color { Ok(if let Some(string) = args.find::>()? { Self::from_str(&string.v).at(string.span)? } else if let Some(color) = args.find::()? { - color.to_rgb() + Color::Rgb(color.to_rgb()) } else { let Component(r) = args.expect("red component")?; let Component(g) = args.expect("green component")?; @@ -565,7 +565,7 @@ impl Color { color: Color, ) -> SourceResult { Ok(if let Some(color) = args.find::()? { - color.to_cmyk() + Color::Cmyk(color.to_cmyk()) } else { let RatioComponent(c) = args.expect("cyan component")?; let RatioComponent(m) = args.expect("magenta component")?; @@ -622,7 +622,7 @@ impl Color { color: Color, ) -> SourceResult { Ok(if let Some(color) = args.find::()? { - color.to_hsl() + Color::Hsl(color.to_hsl()) } else { let h: Angle = args.expect("hue component")?; let Component(s) = args.expect("saturation component")?; @@ -679,7 +679,7 @@ impl Color { color: Color, ) -> SourceResult { Ok(if let Some(color) = args.find::()? { - color.to_hsv() + Color::Hsv(color.to_hsv()) } else { let h: Angle = args.expect("hue component")?; let Component(s) = args.expect("saturation component")?; @@ -830,7 +830,7 @@ impl Color { /// omitted if it is equal to `ff` (255 / 100%). #[func] pub fn to_hex(self) -> EcoString { - let [r, g, b, a] = self.to_rgb().to_vec4_u8(); + let (r, g, b, a) = self.to_rgb().into_format::().into_components(); if a != 255 { eco_format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a) } else { @@ -886,20 +886,21 @@ impl Color { /// The factor to saturate the color by. factor: Ratio, ) -> SourceResult { + let f = factor.get() as f32; Ok(match self { - Self::Luma(_) => { - bail!( - span, "cannot saturate grayscale color"; - hint: "try converting your color to RGB first" - ); + Self::Luma(_) => bail!( + span, "cannot saturate grayscale color"; + hint: "try converting your color to RGB first" + ), + Self::Hsl(c) => Self::Hsl(c.saturate(f)), + Self::Hsv(c) => Self::Hsv(c.saturate(f)), + Self::Oklab(_) + | Self::Oklch(_) + | Self::LinearRgb(_) + | Self::Rgb(_) + | Self::Cmyk(_) => { + Color::Hsv(self.to_hsv().saturate(f)).to_space(self.space()) } - Self::Oklab(_) => self.to_hsv().saturate(span, factor)?.to_oklab(), - Self::Oklch(_) => self.to_hsv().saturate(span, factor)?.to_oklch(), - Self::LinearRgb(_) => self.to_hsv().saturate(span, factor)?.to_linear_rgb(), - Self::Rgb(_) => self.to_hsv().saturate(span, factor)?.to_rgb(), - Self::Cmyk(_) => self.to_hsv().saturate(span, factor)?.to_cmyk(), - Self::Hsl(c) => Self::Hsl(c.saturate(factor.get() as f32)), - Self::Hsv(c) => Self::Hsv(c.saturate(factor.get() as f32)), }) } @@ -911,20 +912,21 @@ impl Color { /// The factor to desaturate the color by. factor: Ratio, ) -> SourceResult { + let f = factor.get() as f32; Ok(match self { - Self::Luma(_) => { - bail!( - span, "cannot desaturate grayscale color"; - hint: "try converting your color to RGB first" - ); + Self::Luma(_) => bail!( + span, "cannot desaturate grayscale color"; + hint: "try converting your color to RGB first" + ), + Self::Hsl(c) => Self::Hsl(c.desaturate(f)), + Self::Hsv(c) => Self::Hsv(c.desaturate(f)), + Self::Oklab(_) + | Self::Oklch(_) + | Self::LinearRgb(_) + | Self::Rgb(_) + | Self::Cmyk(_) => { + Color::Hsv(self.to_hsv().desaturate(f)).to_space(self.space()) } - Self::Oklab(_) => self.to_hsv().desaturate(span, factor)?.to_oklab(), - Self::Oklch(_) => self.to_hsv().desaturate(span, factor)?.to_oklch(), - Self::LinearRgb(_) => self.to_hsv().desaturate(span, factor)?.to_linear_rgb(), - Self::Rgb(_) => self.to_hsv().desaturate(span, factor)?.to_rgb(), - Self::Cmyk(_) => self.to_hsv().desaturate(span, factor)?.to_cmyk(), - Self::Hsl(c) => Self::Hsl(c.desaturate(factor.get() as f32)), - Self::Hsv(c) => Self::Hsv(c.desaturate(factor.get() as f32)), }) } @@ -994,23 +996,17 @@ impl Color { ) -> SourceResult { Ok(match space { ColorSpace::Oklch => { - let Self::Oklch(oklch) = self.to_oklch() else { - unreachable!(); - }; + let oklch = self.to_oklch(); let rotated = oklch.shift_hue(angle.to_deg() as f32); Self::Oklch(rotated).to_space(self.space()) } ColorSpace::Hsl => { - let Self::Hsl(hsl) = self.to_hsl() else { - unreachable!(); - }; + let hsl = self.to_hsl(); let rotated = hsl.shift_hue(angle.to_deg() as f32); Self::Hsl(rotated).to_space(self.space()) } ColorSpace::Hsv => { - let Self::Hsv(hsv) = self.to_hsv() else { - unreachable!(); - }; + let hsv = self.to_hsv(); let rotated = hsv.shift_hue(angle.to_deg() as f32); Self::Hsv(rotated).to_space(self.space()) } @@ -1281,19 +1277,19 @@ impl Color { pub fn to_space(self, space: ColorSpace) -> Self { match space { - ColorSpace::Oklab => self.to_oklab(), - ColorSpace::Oklch => self.to_oklch(), - ColorSpace::Srgb => self.to_rgb(), - ColorSpace::LinearRgb => self.to_linear_rgb(), - ColorSpace::Hsl => self.to_hsl(), - ColorSpace::Hsv => self.to_hsv(), - ColorSpace::Cmyk => self.to_cmyk(), - ColorSpace::D65Gray => self.to_luma(), + ColorSpace::D65Gray => Self::Luma(self.to_luma()), + ColorSpace::Oklab => Self::Oklab(self.to_oklab()), + ColorSpace::Oklch => Self::Oklch(self.to_oklch()), + ColorSpace::Srgb => Self::Rgb(self.to_rgb()), + ColorSpace::LinearRgb => Self::LinearRgb(self.to_linear_rgb()), + ColorSpace::Cmyk => Self::Cmyk(self.to_cmyk()), + ColorSpace::Hsl => Self::Hsl(self.to_hsl()), + ColorSpace::Hsv => Self::Hsv(self.to_hsv()), } } - pub fn to_luma(self) -> Self { - Self::Luma(match self { + pub fn to_luma(self) -> Luma { + match self { Self::Luma(c) => c, Self::Oklab(c) => Luma::from_color(c), Self::Oklch(c) => Luma::from_color(c), @@ -1302,11 +1298,11 @@ impl Color { Self::Cmyk(c) => Luma::from_color(c.to_rgba()), Self::Hsl(c) => Luma::from_color(c), Self::Hsv(c) => Luma::from_color(c), - }) + } } - pub fn to_oklab(self) -> Self { - Self::Oklab(match self { + pub fn to_oklab(self) -> Oklab { + match self { Self::Luma(c) => Oklab::from_color(c), Self::Oklab(c) => c, Self::Oklch(c) => Oklab::from_color(c), @@ -1315,11 +1311,11 @@ impl Color { Self::Cmyk(c) => Oklab::from_color(c.to_rgba()), Self::Hsl(c) => Oklab::from_color(c), Self::Hsv(c) => Oklab::from_color(c), - }) + } } - pub fn to_oklch(self) -> Self { - Self::Oklch(match self { + pub fn to_oklch(self) -> Oklch { + match self { Self::Luma(c) => Oklch::from_color(c), Self::Oklab(c) => Oklch::from_color(c), Self::Oklch(c) => c, @@ -1328,11 +1324,11 @@ impl Color { Self::Cmyk(c) => Oklch::from_color(c.to_rgba()), Self::Hsl(c) => Oklch::from_color(c), Self::Hsv(c) => Oklch::from_color(c), - }) + } } - pub fn to_rgb(self) -> Self { - Self::Rgb(match self { + pub fn to_rgb(self) -> Rgb { + match self { Self::Luma(c) => Rgb::from_color(c), Self::Oklab(c) => Rgb::from_color(c), Self::Oklch(c) => Rgb::from_color(c), @@ -1341,11 +1337,11 @@ impl Color { Self::Cmyk(c) => Rgb::from_color(c.to_rgba()), Self::Hsl(c) => Rgb::from_color(c), Self::Hsv(c) => Rgb::from_color(c), - }) + } } - pub fn to_linear_rgb(self) -> Self { - Self::LinearRgb(match self { + pub fn to_linear_rgb(self) -> LinearRgb { + match self { Self::Luma(c) => LinearRgb::from_color(c), Self::Oklab(c) => LinearRgb::from_color(c), Self::Oklch(c) => LinearRgb::from_color(c), @@ -1354,11 +1350,11 @@ impl Color { Self::Cmyk(c) => LinearRgb::from_color(c.to_rgba()), Self::Hsl(c) => Rgb::from_color(c).into_linear(), Self::Hsv(c) => Rgb::from_color(c).into_linear(), - }) + } } - pub fn to_cmyk(self) -> Self { - Self::Cmyk(match self { + pub fn to_cmyk(self) -> Cmyk { + match self { Self::Luma(c) => Cmyk::from_luma(c), Self::Oklab(c) => Cmyk::from_rgba(Rgb::from_color(c)), Self::Oklch(c) => Cmyk::from_rgba(Rgb::from_color(c)), @@ -1367,11 +1363,11 @@ impl Color { Self::Cmyk(c) => c, Self::Hsl(c) => Cmyk::from_rgba(Rgb::from_color(c)), Self::Hsv(c) => Cmyk::from_rgba(Rgb::from_color(c)), - }) + } } - pub fn to_hsl(self) -> Self { - Self::Hsl(match self { + pub fn to_hsl(self) -> Hsl { + match self { Self::Luma(c) => Hsl::from_color(c), Self::Oklab(c) => Hsl::from_color(c), Self::Oklch(c) => Hsl::from_color(c), @@ -1380,11 +1376,11 @@ impl Color { Self::Cmyk(c) => Hsl::from_color(c.to_rgba()), Self::Hsl(c) => c, Self::Hsv(c) => Hsl::from_color(c), - }) + } } - pub fn to_hsv(self) -> Self { - Self::Hsv(match self { + pub fn to_hsv(self) -> Hsv { + match self { Self::Luma(c) => Hsv::from_color(c), Self::Oklab(c) => Hsv::from_color(c), Self::Oklch(c) => Hsv::from_color(c), @@ -1393,7 +1389,7 @@ impl Color { Self::Cmyk(c) => Hsv::from_color(c.to_rgba()), Self::Hsl(c) => Hsv::from_color(c), Self::Hsv(c) => c, - }) + } } } diff --git a/crates/typst-render/src/paint.rs b/crates/typst-render/src/paint.rs index ce92fd6a3..6eb9c5826 100644 --- a/crates/typst-render/src/paint.rs +++ b/crates/typst-render/src/paint.rs @@ -255,13 +255,13 @@ pub fn to_sk_paint<'a>( } pub fn to_sk_color(color: Color) -> sk::Color { - let [r, g, b, a] = color.to_rgb().to_vec4(); + let (r, g, b, a) = color.to_rgb().into_components(); sk::Color::from_rgba(r, g, b, a) .expect("components must always be in the range [0..=1]") } pub fn to_sk_color_u8(color: Color) -> sk::ColorU8 { - let [r, g, b, a] = color.to_rgb().to_vec4_u8(); + let (r, g, b, a) = color.to_rgb().into_format::().into_components(); sk::ColorU8::from_rgba(r, g, b, a) } From 15302dbe7a6bd04125c4a56fee24bbbacfb4cc2f Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 19 Jun 2025 17:21:00 +0200 Subject: [PATCH 013/101] Add `typst_utils::display` --- crates/typst-utils/src/lib.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/typst-utils/src/lib.rs b/crates/typst-utils/src/lib.rs index abe6423df..4cfe0c046 100644 --- a/crates/typst-utils/src/lib.rs +++ b/crates/typst-utils/src/lib.rs @@ -23,7 +23,7 @@ pub use self::scalar::Scalar; #[doc(hidden)] pub use once_cell; -use std::fmt::{Debug, Formatter}; +use std::fmt::{Debug, Display, Formatter}; use std::hash::Hash; use std::iter::{Chain, Flatten, Rev}; use std::num::{NonZeroU32, NonZeroUsize}; @@ -52,6 +52,25 @@ where Wrapper(f) } +/// Turn a closure into a struct implementing [`Display`]. +pub fn display(f: F) -> impl Display +where + F: Fn(&mut Formatter) -> std::fmt::Result, +{ + struct Wrapper(F); + + impl Display for Wrapper + where + F: Fn(&mut Formatter) -> std::fmt::Result, + { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + self.0(f) + } + } + + Wrapper(f) +} + /// Calculate a 128-bit siphash of a value. pub fn hash128(value: &T) -> u128 { let mut state = SipHasher13::new(); From 3602d06a155a0567fe2b2e75a4d5970578d0f14f Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 19 Jun 2025 17:45:00 +0200 Subject: [PATCH 014/101] Support for generating native functions at runtime --- crates/typst-library/src/foundations/func.rs | 34 ++++++++++++++++---- crates/typst-macros/src/func.rs | 8 ++--- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/crates/typst-library/src/foundations/func.rs b/crates/typst-library/src/foundations/func.rs index 27eb34eac..9ef812890 100644 --- a/crates/typst-library/src/foundations/func.rs +++ b/crates/typst-library/src/foundations/func.rs @@ -307,7 +307,7 @@ impl Func { ) -> SourceResult { match &self.repr { Repr::Native(native) => { - let value = (native.function)(engine, context, &mut args)?; + let value = (native.function.0)(engine, context, &mut args)?; args.finish()?; Ok(value) } @@ -491,8 +491,8 @@ pub trait NativeFunc { /// Defines a native function. #[derive(Debug)] pub struct NativeFuncData { - /// Invokes the function from Typst. - pub function: fn(&mut Engine, Tracked, &mut Args) -> SourceResult, + /// The implementation of the function. + pub function: NativeFuncPtr, /// The function's normal name (e.g. `align`), as exposed to Typst. pub name: &'static str, /// The function's title case name (e.g. `Align`). @@ -504,11 +504,11 @@ pub struct NativeFuncData { /// Whether this function makes use of context. pub contextual: bool, /// Definitions in the scope of the function. - pub scope: LazyLock, + pub scope: DynLazyLock, /// A list of parameter information for each parameter. - pub params: LazyLock>, + pub params: DynLazyLock>, /// Information about the return value of this function. - pub returns: LazyLock, + pub returns: DynLazyLock, } cast! { @@ -516,6 +516,28 @@ cast! { self => Func::from(self).into_value(), } +/// A pointer to a native function's implementation. +pub struct NativeFuncPtr(pub &'static NativeFuncSignature); + +/// The signature of a native function's implementation. +type NativeFuncSignature = + dyn Fn(&mut Engine, Tracked, &mut Args) -> SourceResult + Send + Sync; + +impl Debug for NativeFuncPtr { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.pad("NativeFuncPtr(..)") + } +} + +/// A `LazyLock` that uses a static closure for initialization instead of only +/// working with function pointers. +/// +/// Can be created from a normal function or closure by prepending with a `&`, +/// e.g. `LazyLock::new(&|| "hello")`. Can be created from a dynamic closure +/// by allocating and then leaking it. This is equivalent to having it +/// statically allocated, but allows for it to be generated at runtime. +type DynLazyLock = LazyLock T + Send + Sync)>; + /// Describes a function parameter. #[derive(Debug, Clone)] pub struct ParamInfo { diff --git a/crates/typst-macros/src/func.rs b/crates/typst-macros/src/func.rs index b8ab7a364..e953dc374 100644 --- a/crates/typst-macros/src/func.rs +++ b/crates/typst-macros/src/func.rs @@ -315,15 +315,15 @@ fn create_func_data(func: &Func) -> TokenStream { quote! { #foundations::NativeFuncData { - function: #closure, + function: #foundations::NativeFuncPtr(&#closure), name: #name, title: #title, docs: #docs, keywords: &[#(#keywords),*], 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()), + scope: ::std::sync::LazyLock::new(&|| #scope), + params: ::std::sync::LazyLock::new(&|| ::std::vec![#(#params),*]), + returns: ::std::sync::LazyLock::new(&|| <#returns as #foundations::Reflect>::output()), } } } From e9dc4bb20404037cf192c19f00a010ff3bb1a10b Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 23 Jun 2025 11:12:58 +0200 Subject: [PATCH 015/101] Typed HTML API (#6476) --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/typst-ide/src/complete.rs | 9 + crates/typst-ide/src/tests.rs | 6 +- crates/typst-library/src/foundations/float.rs | 22 + crates/typst-library/src/html/dom.rs | 460 ++++++---- crates/typst-library/src/html/mod.rs | 2 + crates/typst-library/src/html/typed.rs | 868 ++++++++++++++++++ crates/typst-utils/src/pico.rs | 52 ++ tests/ref/html/html-typed.html | 63 ++ tests/suite/html/typed.typ | 187 ++++ 11 files changed, 1513 insertions(+), 160 deletions(-) create mode 100644 crates/typst-library/src/html/typed.rs create mode 100644 tests/ref/html/html-typed.html create mode 100644 tests/suite/html/typed.typ diff --git a/Cargo.lock b/Cargo.lock index 218fa2e4d..58cac3c58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2863,7 +2863,7 @@ dependencies = [ [[package]] name = "typst-assets" version = "0.13.1" -source = "git+https://github.com/typst/typst-assets?rev=c74e539#c74e539b090070a0c66fd007c550f5b6d3b724bd" +source = "git+https://github.com/typst/typst-assets?rev=c1089b4#c1089b46c461bdde579c55caa941a3cc7dec3e8a" [[package]] name = "typst-cli" diff --git a/Cargo.toml b/Cargo.toml index 03141cbbf..72ab9094d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ typst-svg = { path = "crates/typst-svg", version = "0.13.1" } typst-syntax = { path = "crates/typst-syntax", version = "0.13.1" } typst-timing = { path = "crates/typst-timing", version = "0.13.1" } typst-utils = { path = "crates/typst-utils", version = "0.13.1" } -typst-assets = { git = "https://github.com/typst/typst-assets", rev = "c74e539" } +typst-assets = { git = "https://github.com/typst/typst-assets", rev = "c1089b4" } typst-dev-assets = { git = "https://github.com/typst/typst-dev-assets", rev = "fddbf8b" } arrayvec = "0.7.4" az = "1.2" diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 47727743f..536423318 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -1848,4 +1848,13 @@ mod tests { .must_include(["\"New Computer Modern Math\""]) .must_exclude(["\"Libertinus Serif\""]); } + + #[test] + fn test_autocomplete_typed_html() { + test("#html.div(translate: )", -2) + .must_include(["true", "false"]) + .must_exclude(["\"yes\"", "\"no\""]); + test("#html.input(value: )", -2).must_include(["float", "string", "red", "blue"]); + test("#html.div(role: )", -2).must_include(["\"alertdialog\""]); + } } diff --git a/crates/typst-ide/src/tests.rs b/crates/typst-ide/src/tests.rs index 5edc05f17..dd5c230ad 100644 --- a/crates/typst-ide/src/tests.rs +++ b/crates/typst-ide/src/tests.rs @@ -10,7 +10,7 @@ use typst::syntax::package::{PackageSpec, PackageVersion}; use typst::syntax::{FileId, Source, VirtualPath}; use typst::text::{Font, FontBook, TextElem, TextSize}; use typst::utils::{singleton, LazyHash}; -use typst::{Library, World}; +use typst::{Feature, Library, World}; use crate::IdeWorld; @@ -168,7 +168,9 @@ 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::default(); + let mut lib = typst::Library::builder() + .with_features([Feature::Html].into_iter().collect()) + .build(); lib.styles .set(PageElem::set_width(Smart::Custom(Abs::pt(120.0).into()))); lib.styles.set(PageElem::set_height(Smart::Auto)); diff --git a/crates/typst-library/src/foundations/float.rs b/crates/typst-library/src/foundations/float.rs index 21d0a8d81..353e498d3 100644 --- a/crates/typst-library/src/foundations/float.rs +++ b/crates/typst-library/src/foundations/float.rs @@ -210,3 +210,25 @@ cast! { fn parse_float(s: EcoString) -> Result { s.replace(repr::MINUS_SIGN, "-").parse() } + +/// A floating-point number that must be positive (strictly larger than zero). +#[derive(Debug, Copy, Clone, PartialEq, PartialOrd)] +pub struct PositiveF64(f64); + +impl PositiveF64 { + /// Wrap a float if it is positive. + pub fn new(value: f64) -> Option { + (value > 0.0).then_some(Self(value)) + } + + /// Get the underlying value. + pub fn get(self) -> f64 { + self.0 + } +} + +cast! { + PositiveF64, + self => self.get().into_value(), + v: f64 => Self::new(v).ok_or("number must be positive")?, +} diff --git a/crates/typst-library/src/html/dom.rs b/crates/typst-library/src/html/dom.rs index 1b725d543..35d513c10 100644 --- a/crates/typst-library/src/html/dom.rs +++ b/crates/typst-library/src/html/dom.rs @@ -188,7 +188,7 @@ cast! { .collect::>()?), } -/// An attribute of an HTML. +/// An attribute of an HTML element. #[derive(Copy, Clone, Eq, PartialEq, Hash)] pub struct HtmlAttr(PicoStr); @@ -347,135 +347,124 @@ pub mod charsets { } /// Predefined constants for HTML tags. +#[allow(non_upper_case_globals)] pub mod tag { use super::HtmlTag; - macro_rules! tags { - ($($tag:ident)*) => { - $(#[allow(non_upper_case_globals)] - pub const $tag: HtmlTag = HtmlTag::constant( - stringify!($tag) - );)* - } - } + pub const a: HtmlTag = HtmlTag::constant("a"); + pub const abbr: HtmlTag = HtmlTag::constant("abbr"); + pub const address: HtmlTag = HtmlTag::constant("address"); + pub const area: HtmlTag = HtmlTag::constant("area"); + pub const article: HtmlTag = HtmlTag::constant("article"); + pub const aside: HtmlTag = HtmlTag::constant("aside"); + pub const audio: HtmlTag = HtmlTag::constant("audio"); + pub const b: HtmlTag = HtmlTag::constant("b"); + pub const base: HtmlTag = HtmlTag::constant("base"); + pub const bdi: HtmlTag = HtmlTag::constant("bdi"); + pub const bdo: HtmlTag = HtmlTag::constant("bdo"); + pub const blockquote: HtmlTag = HtmlTag::constant("blockquote"); + pub const body: HtmlTag = HtmlTag::constant("body"); + pub const br: HtmlTag = HtmlTag::constant("br"); + pub const button: HtmlTag = HtmlTag::constant("button"); + pub const canvas: HtmlTag = HtmlTag::constant("canvas"); + pub const caption: HtmlTag = HtmlTag::constant("caption"); + pub const cite: HtmlTag = HtmlTag::constant("cite"); + pub const code: HtmlTag = HtmlTag::constant("code"); + pub const col: HtmlTag = HtmlTag::constant("col"); + pub const colgroup: HtmlTag = HtmlTag::constant("colgroup"); + pub const data: HtmlTag = HtmlTag::constant("data"); + pub const datalist: HtmlTag = HtmlTag::constant("datalist"); + pub const dd: HtmlTag = HtmlTag::constant("dd"); + pub const del: HtmlTag = HtmlTag::constant("del"); + pub const details: HtmlTag = HtmlTag::constant("details"); + pub const dfn: HtmlTag = HtmlTag::constant("dfn"); + pub const dialog: HtmlTag = HtmlTag::constant("dialog"); + pub const div: HtmlTag = HtmlTag::constant("div"); + pub const dl: HtmlTag = HtmlTag::constant("dl"); + pub const dt: HtmlTag = HtmlTag::constant("dt"); + pub const em: HtmlTag = HtmlTag::constant("em"); + pub const embed: HtmlTag = HtmlTag::constant("embed"); + pub const fieldset: HtmlTag = HtmlTag::constant("fieldset"); + pub const figcaption: HtmlTag = HtmlTag::constant("figcaption"); + pub const figure: HtmlTag = HtmlTag::constant("figure"); + pub const footer: HtmlTag = HtmlTag::constant("footer"); + pub const form: HtmlTag = HtmlTag::constant("form"); + pub const h1: HtmlTag = HtmlTag::constant("h1"); + pub const h2: HtmlTag = HtmlTag::constant("h2"); + pub const h3: HtmlTag = HtmlTag::constant("h3"); + pub const h4: HtmlTag = HtmlTag::constant("h4"); + pub const h5: HtmlTag = HtmlTag::constant("h5"); + pub const h6: HtmlTag = HtmlTag::constant("h6"); + pub const head: HtmlTag = HtmlTag::constant("head"); + pub const header: HtmlTag = HtmlTag::constant("header"); + pub const hgroup: HtmlTag = HtmlTag::constant("hgroup"); + pub const hr: HtmlTag = HtmlTag::constant("hr"); + pub const html: HtmlTag = HtmlTag::constant("html"); + pub const i: HtmlTag = HtmlTag::constant("i"); + pub const iframe: HtmlTag = HtmlTag::constant("iframe"); + pub const img: HtmlTag = HtmlTag::constant("img"); + pub const input: HtmlTag = HtmlTag::constant("input"); + pub const ins: HtmlTag = HtmlTag::constant("ins"); + pub const kbd: HtmlTag = HtmlTag::constant("kbd"); + pub const label: HtmlTag = HtmlTag::constant("label"); + pub const legend: HtmlTag = HtmlTag::constant("legend"); + pub const li: HtmlTag = HtmlTag::constant("li"); + pub const link: HtmlTag = HtmlTag::constant("link"); + pub const main: HtmlTag = HtmlTag::constant("main"); + pub const map: HtmlTag = HtmlTag::constant("map"); + pub const mark: HtmlTag = HtmlTag::constant("mark"); + pub const menu: HtmlTag = HtmlTag::constant("menu"); + pub const meta: HtmlTag = HtmlTag::constant("meta"); + pub const meter: HtmlTag = HtmlTag::constant("meter"); + pub const nav: HtmlTag = HtmlTag::constant("nav"); + pub const noscript: HtmlTag = HtmlTag::constant("noscript"); + pub const object: HtmlTag = HtmlTag::constant("object"); + pub const ol: HtmlTag = HtmlTag::constant("ol"); + pub const optgroup: HtmlTag = HtmlTag::constant("optgroup"); + pub const option: HtmlTag = HtmlTag::constant("option"); + pub const output: HtmlTag = HtmlTag::constant("output"); + pub const p: HtmlTag = HtmlTag::constant("p"); + pub const picture: HtmlTag = HtmlTag::constant("picture"); + pub const pre: HtmlTag = HtmlTag::constant("pre"); + pub const progress: HtmlTag = HtmlTag::constant("progress"); + pub const q: HtmlTag = HtmlTag::constant("q"); + pub const rp: HtmlTag = HtmlTag::constant("rp"); + pub const rt: HtmlTag = HtmlTag::constant("rt"); + pub const ruby: HtmlTag = HtmlTag::constant("ruby"); + pub const s: HtmlTag = HtmlTag::constant("s"); + pub const samp: HtmlTag = HtmlTag::constant("samp"); + pub const script: HtmlTag = HtmlTag::constant("script"); + pub const search: HtmlTag = HtmlTag::constant("search"); + pub const section: HtmlTag = HtmlTag::constant("section"); + pub const select: HtmlTag = HtmlTag::constant("select"); + pub const slot: HtmlTag = HtmlTag::constant("slot"); + pub const small: HtmlTag = HtmlTag::constant("small"); + pub const source: HtmlTag = HtmlTag::constant("source"); + pub const span: HtmlTag = HtmlTag::constant("span"); + pub const strong: HtmlTag = HtmlTag::constant("strong"); + pub const style: HtmlTag = HtmlTag::constant("style"); + pub const sub: HtmlTag = HtmlTag::constant("sub"); + pub const summary: HtmlTag = HtmlTag::constant("summary"); + pub const sup: HtmlTag = HtmlTag::constant("sup"); + pub const table: HtmlTag = HtmlTag::constant("table"); + pub const tbody: HtmlTag = HtmlTag::constant("tbody"); + pub const td: HtmlTag = HtmlTag::constant("td"); + pub const template: HtmlTag = HtmlTag::constant("template"); + pub const textarea: HtmlTag = HtmlTag::constant("textarea"); + pub const tfoot: HtmlTag = HtmlTag::constant("tfoot"); + pub const th: HtmlTag = HtmlTag::constant("th"); + pub const thead: HtmlTag = HtmlTag::constant("thead"); + pub const time: HtmlTag = HtmlTag::constant("time"); + pub const title: HtmlTag = HtmlTag::constant("title"); + pub const tr: HtmlTag = HtmlTag::constant("tr"); + pub const track: HtmlTag = HtmlTag::constant("track"); + pub const u: HtmlTag = HtmlTag::constant("u"); + pub const ul: HtmlTag = HtmlTag::constant("ul"); + pub const var: HtmlTag = HtmlTag::constant("var"); + pub const video: HtmlTag = HtmlTag::constant("video"); + pub const wbr: HtmlTag = HtmlTag::constant("wbr"); - tags! { - a - abbr - address - area - article - aside - audio - b - base - bdi - bdo - blockquote - body - br - button - canvas - caption - cite - code - col - colgroup - data - datalist - dd - del - details - dfn - dialog - div - dl - dt - em - embed - fieldset - figcaption - figure - footer - form - h1 - h2 - h3 - h4 - h5 - h6 - head - header - hgroup - hr - html - i - iframe - img - input - ins - kbd - label - legend - li - link - main - map - mark - menu - meta - meter - nav - noscript - object - ol - optgroup - option - output - p - param - picture - pre - progress - q - rp - rt - ruby - s - samp - script - search - section - select - slot - small - source - span - strong - style - sub - summary - sup - table - tbody - td - template - textarea - tfoot - th - thead - time - title - tr - track - u - ul - var - video - wbr - } - - /// Whether this is a void tag whose associated element may not have a + /// Whether this is a void tag whose associated element may not have /// children. pub fn is_void(tag: HtmlTag) -> bool { matches!( @@ -490,7 +479,6 @@ pub mod tag { | self::input | self::link | self::meta - | self::param | self::source | self::track | self::wbr @@ -629,36 +617,196 @@ pub mod tag { } } -/// Predefined constants for HTML attributes. -/// -/// Note: These are very incomplete. #[allow(non_upper_case_globals)] +#[rustfmt::skip] pub mod attr { - use super::HtmlAttr; - - macro_rules! attrs { - ($($attr:ident)*) => { - $(#[allow(non_upper_case_globals)] - pub const $attr: HtmlAttr = HtmlAttr::constant( - stringify!($attr) - );)* - } - } - - attrs! { - charset - cite - colspan - content - href - name - reversed - role - rowspan - start - style - value - } - + use crate::html::HtmlAttr; + pub const abbr: HtmlAttr = HtmlAttr::constant("abbr"); + pub const accept: HtmlAttr = HtmlAttr::constant("accept"); + pub const accept_charset: HtmlAttr = HtmlAttr::constant("accept-charset"); + pub const accesskey: HtmlAttr = HtmlAttr::constant("accesskey"); + pub const action: HtmlAttr = HtmlAttr::constant("action"); + pub const allow: HtmlAttr = HtmlAttr::constant("allow"); + pub const allowfullscreen: HtmlAttr = HtmlAttr::constant("allowfullscreen"); + pub const alpha: HtmlAttr = HtmlAttr::constant("alpha"); + pub const alt: HtmlAttr = HtmlAttr::constant("alt"); + pub const aria_activedescendant: HtmlAttr = HtmlAttr::constant("aria-activedescendant"); + pub const aria_atomic: HtmlAttr = HtmlAttr::constant("aria-atomic"); + pub const aria_autocomplete: HtmlAttr = HtmlAttr::constant("aria-autocomplete"); + pub const aria_busy: HtmlAttr = HtmlAttr::constant("aria-busy"); + pub const aria_checked: HtmlAttr = HtmlAttr::constant("aria-checked"); + pub const aria_colcount: HtmlAttr = HtmlAttr::constant("aria-colcount"); + pub const aria_colindex: HtmlAttr = HtmlAttr::constant("aria-colindex"); + pub const aria_colspan: HtmlAttr = HtmlAttr::constant("aria-colspan"); + pub const aria_controls: HtmlAttr = HtmlAttr::constant("aria-controls"); + pub const aria_current: HtmlAttr = HtmlAttr::constant("aria-current"); + pub const aria_describedby: HtmlAttr = HtmlAttr::constant("aria-describedby"); + pub const aria_details: HtmlAttr = HtmlAttr::constant("aria-details"); + pub const aria_disabled: HtmlAttr = HtmlAttr::constant("aria-disabled"); + pub const aria_errormessage: HtmlAttr = HtmlAttr::constant("aria-errormessage"); + pub const aria_expanded: HtmlAttr = HtmlAttr::constant("aria-expanded"); + pub const aria_flowto: HtmlAttr = HtmlAttr::constant("aria-flowto"); + pub const aria_haspopup: HtmlAttr = HtmlAttr::constant("aria-haspopup"); + pub const aria_hidden: HtmlAttr = HtmlAttr::constant("aria-hidden"); + pub const aria_invalid: HtmlAttr = HtmlAttr::constant("aria-invalid"); + pub const aria_keyshortcuts: HtmlAttr = HtmlAttr::constant("aria-keyshortcuts"); + pub const aria_label: HtmlAttr = HtmlAttr::constant("aria-label"); + pub const aria_labelledby: HtmlAttr = HtmlAttr::constant("aria-labelledby"); pub const aria_level: HtmlAttr = HtmlAttr::constant("aria-level"); + pub const aria_live: HtmlAttr = HtmlAttr::constant("aria-live"); + pub const aria_modal: HtmlAttr = HtmlAttr::constant("aria-modal"); + pub const aria_multiline: HtmlAttr = HtmlAttr::constant("aria-multiline"); + pub const aria_multiselectable: HtmlAttr = HtmlAttr::constant("aria-multiselectable"); + pub const aria_orientation: HtmlAttr = HtmlAttr::constant("aria-orientation"); + pub const aria_owns: HtmlAttr = HtmlAttr::constant("aria-owns"); + pub const aria_placeholder: HtmlAttr = HtmlAttr::constant("aria-placeholder"); + pub const aria_posinset: HtmlAttr = HtmlAttr::constant("aria-posinset"); + pub const aria_pressed: HtmlAttr = HtmlAttr::constant("aria-pressed"); + pub const aria_readonly: HtmlAttr = HtmlAttr::constant("aria-readonly"); + pub const aria_relevant: HtmlAttr = HtmlAttr::constant("aria-relevant"); + pub const aria_required: HtmlAttr = HtmlAttr::constant("aria-required"); + pub const aria_roledescription: HtmlAttr = HtmlAttr::constant("aria-roledescription"); + pub const aria_rowcount: HtmlAttr = HtmlAttr::constant("aria-rowcount"); + pub const aria_rowindex: HtmlAttr = HtmlAttr::constant("aria-rowindex"); + pub const aria_rowspan: HtmlAttr = HtmlAttr::constant("aria-rowspan"); + pub const aria_selected: HtmlAttr = HtmlAttr::constant("aria-selected"); + pub const aria_setsize: HtmlAttr = HtmlAttr::constant("aria-setsize"); + pub const aria_sort: HtmlAttr = HtmlAttr::constant("aria-sort"); + pub const aria_valuemax: HtmlAttr = HtmlAttr::constant("aria-valuemax"); + pub const aria_valuemin: HtmlAttr = HtmlAttr::constant("aria-valuemin"); + pub const aria_valuenow: HtmlAttr = HtmlAttr::constant("aria-valuenow"); + pub const aria_valuetext: HtmlAttr = HtmlAttr::constant("aria-valuetext"); + pub const r#as: HtmlAttr = HtmlAttr::constant("as"); + pub const r#async: HtmlAttr = HtmlAttr::constant("async"); + pub const autocapitalize: HtmlAttr = HtmlAttr::constant("autocapitalize"); + pub const autocomplete: HtmlAttr = HtmlAttr::constant("autocomplete"); + pub const autocorrect: HtmlAttr = HtmlAttr::constant("autocorrect"); + pub const autofocus: HtmlAttr = HtmlAttr::constant("autofocus"); + pub const autoplay: HtmlAttr = HtmlAttr::constant("autoplay"); + pub const blocking: HtmlAttr = HtmlAttr::constant("blocking"); + pub const charset: HtmlAttr = HtmlAttr::constant("charset"); + pub const checked: HtmlAttr = HtmlAttr::constant("checked"); + pub const cite: HtmlAttr = HtmlAttr::constant("cite"); + pub const class: HtmlAttr = HtmlAttr::constant("class"); + pub const closedby: HtmlAttr = HtmlAttr::constant("closedby"); + pub const color: HtmlAttr = HtmlAttr::constant("color"); + pub const colorspace: HtmlAttr = HtmlAttr::constant("colorspace"); + pub const cols: HtmlAttr = HtmlAttr::constant("cols"); + pub const colspan: HtmlAttr = HtmlAttr::constant("colspan"); + pub const command: HtmlAttr = HtmlAttr::constant("command"); + pub const commandfor: HtmlAttr = HtmlAttr::constant("commandfor"); + pub const content: HtmlAttr = HtmlAttr::constant("content"); + pub const contenteditable: HtmlAttr = HtmlAttr::constant("contenteditable"); + pub const controls: HtmlAttr = HtmlAttr::constant("controls"); + pub const coords: HtmlAttr = HtmlAttr::constant("coords"); + pub const crossorigin: HtmlAttr = HtmlAttr::constant("crossorigin"); + pub const data: HtmlAttr = HtmlAttr::constant("data"); + pub const datetime: HtmlAttr = HtmlAttr::constant("datetime"); + pub const decoding: HtmlAttr = HtmlAttr::constant("decoding"); + pub const default: HtmlAttr = HtmlAttr::constant("default"); + pub const defer: HtmlAttr = HtmlAttr::constant("defer"); + pub const dir: HtmlAttr = HtmlAttr::constant("dir"); + pub const dirname: HtmlAttr = HtmlAttr::constant("dirname"); + pub const disabled: HtmlAttr = HtmlAttr::constant("disabled"); + pub const download: HtmlAttr = HtmlAttr::constant("download"); + pub const draggable: HtmlAttr = HtmlAttr::constant("draggable"); + pub const enctype: HtmlAttr = HtmlAttr::constant("enctype"); + pub const enterkeyhint: HtmlAttr = HtmlAttr::constant("enterkeyhint"); + pub const fetchpriority: HtmlAttr = HtmlAttr::constant("fetchpriority"); + pub const r#for: HtmlAttr = HtmlAttr::constant("for"); + pub const form: HtmlAttr = HtmlAttr::constant("form"); + pub const formaction: HtmlAttr = HtmlAttr::constant("formaction"); + pub const formenctype: HtmlAttr = HtmlAttr::constant("formenctype"); + pub const formmethod: HtmlAttr = HtmlAttr::constant("formmethod"); + pub const formnovalidate: HtmlAttr = HtmlAttr::constant("formnovalidate"); + pub const formtarget: HtmlAttr = HtmlAttr::constant("formtarget"); + pub const headers: HtmlAttr = HtmlAttr::constant("headers"); + pub const height: HtmlAttr = HtmlAttr::constant("height"); + pub const hidden: HtmlAttr = HtmlAttr::constant("hidden"); + pub const high: HtmlAttr = HtmlAttr::constant("high"); + pub const href: HtmlAttr = HtmlAttr::constant("href"); + pub const hreflang: HtmlAttr = HtmlAttr::constant("hreflang"); + pub const http_equiv: HtmlAttr = HtmlAttr::constant("http-equiv"); + pub const id: HtmlAttr = HtmlAttr::constant("id"); + pub const imagesizes: HtmlAttr = HtmlAttr::constant("imagesizes"); + pub const imagesrcset: HtmlAttr = HtmlAttr::constant("imagesrcset"); + pub const inert: HtmlAttr = HtmlAttr::constant("inert"); + pub const inputmode: HtmlAttr = HtmlAttr::constant("inputmode"); + pub const integrity: HtmlAttr = HtmlAttr::constant("integrity"); + pub const is: HtmlAttr = HtmlAttr::constant("is"); + pub const ismap: HtmlAttr = HtmlAttr::constant("ismap"); + pub const itemid: HtmlAttr = HtmlAttr::constant("itemid"); + pub const itemprop: HtmlAttr = HtmlAttr::constant("itemprop"); + pub const itemref: HtmlAttr = HtmlAttr::constant("itemref"); + pub const itemscope: HtmlAttr = HtmlAttr::constant("itemscope"); + pub const itemtype: HtmlAttr = HtmlAttr::constant("itemtype"); + pub const kind: HtmlAttr = HtmlAttr::constant("kind"); + pub const label: HtmlAttr = HtmlAttr::constant("label"); + pub const lang: HtmlAttr = HtmlAttr::constant("lang"); + pub const list: HtmlAttr = HtmlAttr::constant("list"); + pub const loading: HtmlAttr = HtmlAttr::constant("loading"); + pub const r#loop: HtmlAttr = HtmlAttr::constant("loop"); + pub const low: HtmlAttr = HtmlAttr::constant("low"); + pub const max: HtmlAttr = HtmlAttr::constant("max"); + pub const maxlength: HtmlAttr = HtmlAttr::constant("maxlength"); + pub const media: HtmlAttr = HtmlAttr::constant("media"); + pub const method: HtmlAttr = HtmlAttr::constant("method"); + pub const min: HtmlAttr = HtmlAttr::constant("min"); + pub const minlength: HtmlAttr = HtmlAttr::constant("minlength"); + pub const multiple: HtmlAttr = HtmlAttr::constant("multiple"); + pub const muted: HtmlAttr = HtmlAttr::constant("muted"); + pub const name: HtmlAttr = HtmlAttr::constant("name"); + pub const nomodule: HtmlAttr = HtmlAttr::constant("nomodule"); + pub const nonce: HtmlAttr = HtmlAttr::constant("nonce"); + pub const novalidate: HtmlAttr = HtmlAttr::constant("novalidate"); + pub const open: HtmlAttr = HtmlAttr::constant("open"); + pub const optimum: HtmlAttr = HtmlAttr::constant("optimum"); + pub const pattern: HtmlAttr = HtmlAttr::constant("pattern"); + pub const ping: HtmlAttr = HtmlAttr::constant("ping"); + pub const placeholder: HtmlAttr = HtmlAttr::constant("placeholder"); + pub const playsinline: HtmlAttr = HtmlAttr::constant("playsinline"); + pub const popover: HtmlAttr = HtmlAttr::constant("popover"); + pub const popovertarget: HtmlAttr = HtmlAttr::constant("popovertarget"); + pub const popovertargetaction: HtmlAttr = HtmlAttr::constant("popovertargetaction"); + pub const poster: HtmlAttr = HtmlAttr::constant("poster"); + pub const preload: HtmlAttr = HtmlAttr::constant("preload"); + pub const readonly: HtmlAttr = HtmlAttr::constant("readonly"); + pub const referrerpolicy: HtmlAttr = HtmlAttr::constant("referrerpolicy"); + pub const rel: HtmlAttr = HtmlAttr::constant("rel"); + pub const required: HtmlAttr = HtmlAttr::constant("required"); + pub const reversed: HtmlAttr = HtmlAttr::constant("reversed"); + pub const role: HtmlAttr = HtmlAttr::constant("role"); + pub const rows: HtmlAttr = HtmlAttr::constant("rows"); + pub const rowspan: HtmlAttr = HtmlAttr::constant("rowspan"); + pub const sandbox: HtmlAttr = HtmlAttr::constant("sandbox"); + pub const scope: HtmlAttr = HtmlAttr::constant("scope"); + pub const selected: HtmlAttr = HtmlAttr::constant("selected"); + pub const shadowrootclonable: HtmlAttr = HtmlAttr::constant("shadowrootclonable"); + pub const shadowrootcustomelementregistry: HtmlAttr = HtmlAttr::constant("shadowrootcustomelementregistry"); + pub const shadowrootdelegatesfocus: HtmlAttr = HtmlAttr::constant("shadowrootdelegatesfocus"); + pub const shadowrootmode: HtmlAttr = HtmlAttr::constant("shadowrootmode"); + pub const shadowrootserializable: HtmlAttr = HtmlAttr::constant("shadowrootserializable"); + pub const shape: HtmlAttr = HtmlAttr::constant("shape"); + pub const size: HtmlAttr = HtmlAttr::constant("size"); + pub const sizes: HtmlAttr = HtmlAttr::constant("sizes"); + pub const slot: HtmlAttr = HtmlAttr::constant("slot"); + pub const span: HtmlAttr = HtmlAttr::constant("span"); + pub const spellcheck: HtmlAttr = HtmlAttr::constant("spellcheck"); + pub const src: HtmlAttr = HtmlAttr::constant("src"); + pub const srcdoc: HtmlAttr = HtmlAttr::constant("srcdoc"); + pub const srclang: HtmlAttr = HtmlAttr::constant("srclang"); + pub const srcset: HtmlAttr = HtmlAttr::constant("srcset"); + pub const start: HtmlAttr = HtmlAttr::constant("start"); + pub const step: HtmlAttr = HtmlAttr::constant("step"); + pub const style: HtmlAttr = HtmlAttr::constant("style"); + pub const tabindex: HtmlAttr = HtmlAttr::constant("tabindex"); + pub const target: HtmlAttr = HtmlAttr::constant("target"); + pub const title: HtmlAttr = HtmlAttr::constant("title"); + pub const translate: HtmlAttr = HtmlAttr::constant("translate"); + pub const r#type: HtmlAttr = HtmlAttr::constant("type"); + pub const usemap: HtmlAttr = HtmlAttr::constant("usemap"); + pub const value: HtmlAttr = HtmlAttr::constant("value"); + pub const width: HtmlAttr = HtmlAttr::constant("width"); + pub const wrap: HtmlAttr = HtmlAttr::constant("wrap"); + pub const writingsuggestions: HtmlAttr = HtmlAttr::constant("writingsuggestions"); } diff --git a/crates/typst-library/src/html/mod.rs b/crates/typst-library/src/html/mod.rs index 1d88781c1..7fc8adecd 100644 --- a/crates/typst-library/src/html/mod.rs +++ b/crates/typst-library/src/html/mod.rs @@ -1,6 +1,7 @@ //! HTML output. mod dom; +mod typed; pub use self::dom::*; @@ -14,6 +15,7 @@ pub fn module() -> Module { html.start_category(crate::Category::Html); html.define_elem::(); html.define_elem::(); + self::typed::define(&mut html); Module::new("html", html) } diff --git a/crates/typst-library/src/html/typed.rs b/crates/typst-library/src/html/typed.rs new file mode 100644 index 000000000..1e7c1ad6f --- /dev/null +++ b/crates/typst-library/src/html/typed.rs @@ -0,0 +1,868 @@ +//! The typed HTML element API (e.g. `html.div`). +//! +//! The typed API is backed by generated data derived from the HTML +//! specification. See [generated] and `tools/codegen`. + +use std::fmt::Write; +use std::num::{NonZeroI64, NonZeroU64}; +use std::sync::LazyLock; + +use bumpalo::Bump; +use comemo::Tracked; +use ecow::{eco_format, eco_vec, EcoString}; +use typst_assets::html as data; +use typst_macros::cast; + +use crate::diag::{bail, At, Hint, HintedStrResult, SourceResult}; +use crate::engine::Engine; +use crate::foundations::{ + Args, Array, AutoValue, CastInfo, Content, Context, Datetime, Dict, Duration, + FromValue, IntoValue, NativeFuncData, NativeFuncPtr, NoneValue, ParamInfo, + PositiveF64, Reflect, Scope, Str, Type, Value, +}; +use crate::html::tag; +use crate::html::{HtmlAttr, HtmlAttrs, HtmlElem, HtmlTag}; +use crate::layout::{Axes, Axis, Dir, Length}; +use crate::visualize::Color; + +/// Hook up all typed HTML definitions. +pub(super) fn define(html: &mut Scope) { + for data in FUNCS.iter() { + html.define_func_with_data(data); + } +} + +/// Lazily created functions for all typed HTML constructors. +static FUNCS: LazyLock> = LazyLock::new(|| { + // Leaking is okay here. It's not meaningfully different from having + // memory-managed values as `FUNCS` is a static. + let bump = Box::leak(Box::new(Bump::new())); + data::ELEMS.iter().map(|info| create_func_data(info, bump)).collect() +}); + +/// Creates metadata for a native HTML element constructor function. +fn create_func_data( + element: &'static data::ElemInfo, + bump: &'static Bump, +) -> NativeFuncData { + NativeFuncData { + function: NativeFuncPtr(bump.alloc( + move |_: &mut Engine, _: Tracked, args: &mut Args| { + construct(element, args) + }, + )), + name: element.name, + title: { + let title = bump.alloc_str(element.name); + title[0..1].make_ascii_uppercase(); + title + }, + docs: element.docs, + keywords: &[], + contextual: false, + scope: LazyLock::new(&|| Scope::new()), + params: LazyLock::new(bump.alloc(move || create_param_info(element))), + returns: LazyLock::new(&|| CastInfo::Type(Type::of::())), + } +} + +/// Creates parameter signature metadata for an element. +fn create_param_info(element: &'static data::ElemInfo) -> Vec { + let mut params = vec![]; + for attr in element.attributes() { + params.push(ParamInfo { + name: attr.name, + docs: attr.docs, + input: AttrType::convert(attr.ty).input(), + default: None, + positional: false, + named: true, + variadic: false, + required: false, + settable: false, + }); + } + let tag = HtmlTag::constant(element.name); + if !tag::is_void(tag) { + params.push(ParamInfo { + name: "body", + docs: "The contents of the HTML element.", + input: CastInfo::Type(Type::of::()), + default: None, + positional: true, + named: false, + variadic: false, + required: false, + settable: false, + }); + } + params +} + +/// The native constructor function shared by all HTML elements. +fn construct(element: &'static data::ElemInfo, args: &mut Args) -> SourceResult { + let mut attrs = HtmlAttrs::default(); + let mut errors = eco_vec![]; + + args.items.retain(|item| { + let Some(name) = &item.name else { return true }; + let Some(attr) = element.get_attr(name) else { return true }; + + let span = item.value.span; + let value = std::mem::take(&mut item.value.v); + let ty = AttrType::convert(attr.ty); + match ty.cast(value).at(span) { + Ok(Some(string)) => attrs.push(HtmlAttr::constant(attr.name), string), + Ok(None) => {} + Err(diags) => errors.extend(diags), + } + + false + }); + + if !errors.is_empty() { + return Err(errors); + } + + let tag = HtmlTag::constant(element.name); + let mut elem = HtmlElem::new(tag); + if !attrs.0.is_empty() { + elem.push_attrs(attrs); + } + + if !tag::is_void(tag) { + let body = args.eat::()?; + elem.push_body(body); + } + + Ok(elem.into_value()) +} + +/// A dynamic representation of an attribute's type. +/// +/// See the documentation of [`data::Type`] for more details on variants. +enum AttrType { + Presence, + Native(NativeType), + Strings(StringsType), + Union(UnionType), + List(ListType), +} + +impl AttrType { + /// Converts the type definition into a representation suitable for casting + /// and reflection. + const fn convert(ty: data::Type) -> AttrType { + use data::Type; + match ty { + Type::Presence => Self::Presence, + Type::None => Self::of::(), + Type::NoneEmpty => Self::of::(), + Type::NoneUndefined => Self::of::(), + Type::Auto => Self::of::(), + Type::TrueFalse => Self::of::(), + Type::YesNo => Self::of::(), + Type::OnOff => Self::of::(), + Type::Int => Self::of::(), + Type::NonNegativeInt => Self::of::(), + Type::PositiveInt => Self::of::(), + Type::Float => Self::of::(), + Type::PositiveFloat => Self::of::(), + Type::Str => Self::of::(), + Type::Char => Self::of::(), + Type::Datetime => Self::of::(), + Type::Duration => Self::of::(), + Type::Color => Self::of::(), + Type::HorizontalDir => Self::of::(), + Type::IconSize => Self::of::(), + Type::ImageCandidate => Self::of::(), + Type::SourceSize => Self::of::(), + Type::Strings(start, end) => Self::Strings(StringsType { start, end }), + Type::Union(variants) => Self::Union(UnionType(variants)), + Type::List(inner, separator, shorthand) => { + Self::List(ListType { inner, separator, shorthand }) + } + } + } + + /// Produces the dynamic representation of an attribute type backed by a + /// native Rust type. + const fn of() -> Self { + Self::Native(NativeType::of::()) + } + + /// See [`Reflect::input`]. + fn input(&self) -> CastInfo { + match self { + Self::Presence => bool::input(), + Self::Native(ty) => (ty.input)(), + Self::Union(ty) => ty.input(), + Self::Strings(ty) => ty.input(), + Self::List(ty) => ty.input(), + } + } + + /// See [`Reflect::castable`]. + fn castable(&self, value: &Value) -> bool { + match self { + Self::Presence => bool::castable(value), + Self::Native(ty) => (ty.castable)(value), + Self::Union(ty) => ty.castable(value), + Self::Strings(ty) => ty.castable(value), + Self::List(ty) => ty.castable(value), + } + } + + /// Tries to cast the value into this attribute's type and serialize it into + /// an HTML attribute string. + fn cast(&self, value: Value) -> HintedStrResult> { + match self { + Self::Presence => value.cast::().map(|b| b.then(EcoString::new)), + Self::Native(ty) => (ty.cast)(value), + Self::Union(ty) => ty.cast(value), + Self::Strings(ty) => ty.cast(value), + Self::List(ty) => ty.cast(value), + } + } +} + +/// An enumeration with generated string variants. +/// +/// `start` and `end` are used to index into `data::ATTR_STRINGS`. +struct StringsType { + start: usize, + end: usize, +} + +impl StringsType { + fn input(&self) -> CastInfo { + CastInfo::Union( + self.strings() + .iter() + .map(|(val, desc)| CastInfo::Value(val.into_value(), desc)) + .collect(), + ) + } + + fn castable(&self, value: &Value) -> bool { + match value { + Value::Str(s) => self.strings().iter().any(|&(v, _)| v == s.as_str()), + _ => false, + } + } + + fn cast(&self, value: Value) -> HintedStrResult> { + if self.castable(&value) { + value.cast().map(Some) + } else { + Err(self.input().error(&value)) + } + } + + fn strings(&self) -> &'static [(&'static str, &'static str)] { + &data::ATTR_STRINGS[self.start..self.end] + } +} + +/// A type that accepts any of the contained types. +struct UnionType(&'static [data::Type]); + +impl UnionType { + fn input(&self) -> CastInfo { + CastInfo::Union(self.iter().map(|ty| ty.input()).collect()) + } + + fn castable(&self, value: &Value) -> bool { + self.iter().any(|ty| ty.castable(value)) + } + + fn cast(&self, value: Value) -> HintedStrResult> { + for item in self.iter() { + if item.castable(&value) { + return item.cast(value); + } + } + Err(self.input().error(&value)) + } + + fn iter(&self) -> impl Iterator { + self.0.iter().map(|&ty| AttrType::convert(ty)) + } +} + +/// A list of items separated by a specific separator char. +/// +/// - +/// - +struct ListType { + inner: &'static data::Type, + separator: char, + shorthand: bool, +} + +impl ListType { + fn input(&self) -> CastInfo { + if self.shorthand { + Array::input() + self.inner().input() + } else { + Array::input() + } + } + + fn castable(&self, value: &Value) -> bool { + Array::castable(value) || (self.shorthand && self.inner().castable(value)) + } + + fn cast(&self, value: Value) -> HintedStrResult> { + let ty = self.inner(); + if Array::castable(&value) { + let array = value.cast::()?; + let mut out = EcoString::new(); + for (i, item) in array.into_iter().enumerate() { + let item = ty.cast(item)?.unwrap(); + if item.as_str().contains(self.separator) { + let buf; + let name = match self.separator { + ' ' => "space", + ',' => "comma", + _ => { + buf = eco_format!("'{}'", self.separator); + buf.as_str() + } + }; + bail!( + "array item may not contain a {name}"; + hint: "the array attribute will be encoded as a \ + {name}-separated string" + ); + } + if i > 0 { + out.push(self.separator); + if self.separator == ',' { + out.push(' '); + } + } + out.push_str(&item); + } + Ok(Some(out)) + } else if self.shorthand && ty.castable(&value) { + let item = ty.cast(value)?.unwrap(); + Ok(Some(item)) + } else { + Err(self.input().error(&value)) + } + } + + fn inner(&self) -> AttrType { + AttrType::convert(*self.inner) + } +} + +/// A dynamic representation of attribute backed by a native type implementing +/// - the standard `Reflect` and `FromValue` traits for casting from a value, +/// - the special `IntoAttr` trait for conversion into an attribute string. +#[derive(Copy, Clone)] +struct NativeType { + input: fn() -> CastInfo, + cast: fn(Value) -> HintedStrResult>, + castable: fn(&Value) -> bool, +} + +impl NativeType { + /// Creates a dynamic native type from a native Rust type. + const fn of() -> Self { + Self { + cast: |value| { + let this = value.cast::()?; + Ok(Some(this.into_attr())) + }, + input: T::input, + castable: T::castable, + } + } +} + +/// Casts a native type into an HTML attribute. +pub trait IntoAttr: FromValue { + /// Turn the value into an attribute string. + fn into_attr(self) -> EcoString; +} + +impl IntoAttr for Str { + fn into_attr(self) -> EcoString { + self.into() + } +} + +/// A boolean that is encoded as a string: +/// - `false` is encoded as `"false"` +/// - `true` is encoded as `"true"` +pub struct TrueFalseBool(pub bool); + +cast! { + TrueFalseBool, + v: bool => Self(v), +} + +impl IntoAttr for TrueFalseBool { + fn into_attr(self) -> EcoString { + if self.0 { "true" } else { "false" }.into() + } +} + +/// A boolean that is encoded as a string: +/// - `false` is encoded as `"no"` +/// - `true` is encoded as `"yes"` +pub struct YesNoBool(pub bool); + +cast! { + YesNoBool, + v: bool => Self(v), +} + +impl IntoAttr for YesNoBool { + fn into_attr(self) -> EcoString { + if self.0 { "yes" } else { "no" }.into() + } +} + +/// A boolean that is encoded as a string: +/// - `false` is encoded as `"off"` +/// - `true` is encoded as `"on"` +pub struct OnOffBool(pub bool); + +cast! { + OnOffBool, + v: bool => Self(v), +} + +impl IntoAttr for OnOffBool { + fn into_attr(self) -> EcoString { + if self.0 { "on" } else { "off" }.into() + } +} + +impl IntoAttr for AutoValue { + fn into_attr(self) -> EcoString { + "auto".into() + } +} + +impl IntoAttr for NoneValue { + fn into_attr(self) -> EcoString { + "none".into() + } +} + +/// A `none` value that turns into an empty string attribute. +struct NoneEmpty; + +cast! { + NoneEmpty, + _: NoneValue => NoneEmpty, +} + +impl IntoAttr for NoneEmpty { + fn into_attr(self) -> EcoString { + "".into() + } +} + +/// A `none` value that turns into the string `"undefined"`. +struct NoneUndefined; + +cast! { + NoneUndefined, + _: NoneValue => NoneUndefined, +} + +impl IntoAttr for NoneUndefined { + fn into_attr(self) -> EcoString { + "undefined".into() + } +} + +impl IntoAttr for char { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for i64 { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for u64 { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for NonZeroI64 { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for NonZeroU64 { + fn into_attr(self) -> EcoString { + eco_format!("{self}") + } +} + +impl IntoAttr for f64 { + fn into_attr(self) -> EcoString { + // HTML float literal allows all the things that Rust's float `Display` + // impl produces. + eco_format!("{self}") + } +} + +impl IntoAttr for PositiveF64 { + fn into_attr(self) -> EcoString { + self.get().into_attr() + } +} + +impl IntoAttr for Color { + fn into_attr(self) -> EcoString { + eco_format!("{}", css::color(self)) + } +} + +impl IntoAttr for Duration { + fn into_attr(self) -> EcoString { + // https://html.spec.whatwg.org/#valid-duration-string + let mut out = EcoString::new(); + macro_rules! part { + ($s:literal) => { + if !out.is_empty() { + out.push(' '); + } + write!(out, $s).unwrap(); + }; + } + + let [weeks, days, hours, minutes, seconds] = self.decompose(); + if weeks > 0 { + part!("{weeks}w"); + } + if days > 0 { + part!("{days}d"); + } + if hours > 0 { + part!("{hours}h"); + } + if minutes > 0 { + part!("{minutes}m"); + } + if seconds > 0 || out.is_empty() { + part!("{seconds}s"); + } + + out + } +} + +impl IntoAttr for Datetime { + fn into_attr(self) -> EcoString { + let fmt = typst_utils::display(|f| match self { + Self::Date(date) => datetime::date(f, date), + Self::Time(time) => datetime::time(f, time), + Self::Datetime(datetime) => datetime::datetime(f, datetime), + }); + eco_format!("{fmt}") + } +} + +mod datetime { + use std::fmt::{self, Formatter, Write}; + + pub fn datetime(f: &mut Formatter, datetime: time::PrimitiveDateTime) -> fmt::Result { + // https://html.spec.whatwg.org/#valid-global-date-and-time-string + date(f, datetime.date())?; + f.write_char('T')?; + time(f, datetime.time()) + } + + pub fn date(f: &mut Formatter, date: time::Date) -> fmt::Result { + // https://html.spec.whatwg.org/#valid-date-string + write!(f, "{:04}-{:02}-{:02}", date.year(), date.month() as u8, date.day()) + } + + pub fn time(f: &mut Formatter, time: time::Time) -> fmt::Result { + // https://html.spec.whatwg.org/#valid-time-string + write!(f, "{:02}:{:02}", time.hour(), time.minute())?; + if time.second() > 0 { + write!(f, ":{:02}", time.second())?; + } + Ok(()) + } +} + +/// A direction on the X axis: `ltr` or `rtl`. +pub struct HorizontalDir(Dir); + +cast! { + HorizontalDir, + v: Dir => { + if v.axis() == Axis::Y { + bail!("direction must be horizontal"); + } + Self(v) + }, +} + +impl IntoAttr for HorizontalDir { + fn into_attr(self) -> EcoString { + self.0.into_attr() + } +} + +impl IntoAttr for Dir { + fn into_attr(self) -> EcoString { + match self { + Self::LTR => "ltr".into(), + Self::RTL => "rtl".into(), + Self::TTB => "ttb".into(), + Self::BTT => "btt".into(), + } + } +} + +/// A width/height pair for ``. +pub struct IconSize(Axes); + +cast! { + IconSize, + v: Axes => Self(v), +} + +impl IntoAttr for IconSize { + fn into_attr(self) -> EcoString { + eco_format!("{}x{}", self.0.x, self.0.y) + } +} + +/// +pub struct ImageCandidate(EcoString); + +cast! { + ImageCandidate, + mut v: Dict => { + let src = v.take("src")?.cast::()?; + let width: Option = + v.take("width").ok().map(Value::cast).transpose()?; + let density: Option = + v.take("density").ok().map(Value::cast).transpose()?; + v.finish(&["src", "width", "density"])?; + + if src.is_empty() { + bail!("`src` must not be empty"); + } else if src.starts_with(',') || src.ends_with(',') { + bail!("`src` must not start or end with a comma"); + } + + let mut out = src; + match (width, density) { + (None, None) => {} + (Some(width), None) => write!(out, " {width}w").unwrap(), + (None, Some(density)) => write!(out, " {}d", density.get()).unwrap(), + (Some(_), Some(_)) => bail!("cannot specify both `width` and `density`"), + } + + Self(out) + }, +} + +impl IntoAttr for ImageCandidate { + fn into_attr(self) -> EcoString { + self.0 + } +} + +/// +pub struct SourceSize(EcoString); + +cast! { + SourceSize, + mut v: Dict => { + let condition = v.take("condition")?.cast::()?; + let size = v + .take("size")? + .cast::() + .hint("CSS lengths that are not expressible as Typst lengths are not yet supported") + .hint("you can use `html.elem` to create a raw attribute")?; + Self(eco_format!("({condition}) {}", css::length(size))) + }, +} + +impl IntoAttr for SourceSize { + fn into_attr(self) -> EcoString { + self.0 + } +} + +/// Conversion from Typst data types into CSS data types. +/// +/// This can be moved elsewhere once we start supporting more CSS stuff. +mod css { + use std::fmt::{self, Display}; + + use typst_utils::Numeric; + + use crate::layout::Length; + use crate::visualize::{Color, Hsl, LinearRgb, Oklab, Oklch, Rgb}; + + pub fn length(length: Length) -> impl Display { + typst_utils::display(move |f| match (length.abs.is_zero(), length.em.is_zero()) { + (false, false) => { + write!(f, "calc({}pt + {}em)", length.abs.to_pt(), length.em.get()) + } + (true, false) => write!(f, "{}em", length.em.get()), + (_, true) => write!(f, "{}pt", length.abs.to_pt()), + }) + } + + pub fn color(color: Color) -> impl Display { + typst_utils::display(move |f| match color { + Color::Rgb(_) | Color::Cmyk(_) | Color::Luma(_) => rgb(f, color.to_rgb()), + Color::Oklab(v) => oklab(f, v), + Color::Oklch(v) => oklch(f, v), + Color::LinearRgb(v) => linear_rgb(f, v), + Color::Hsl(_) | Color::Hsv(_) => hsl(f, color.to_hsl()), + }) + } + + fn oklab(f: &mut fmt::Formatter<'_>, v: Oklab) -> fmt::Result { + write!( + f, + "oklab({} {} {}{})", + percent(v.l), + number(v.a), + number(v.b), + alpha(v.alpha) + ) + } + + fn oklch(f: &mut fmt::Formatter<'_>, v: Oklch) -> fmt::Result { + write!( + f, + "oklch({} {} {}deg{})", + percent(v.l), + number(v.chroma), + number(v.hue.into_degrees()), + alpha(v.alpha) + ) + } + + fn rgb(f: &mut fmt::Formatter<'_>, v: Rgb) -> fmt::Result { + if let Some(v) = rgb_to_8_bit_lossless(v) { + let (r, g, b, a) = v.into_components(); + write!(f, "#{r:02x}{g:02x}{b:02x}")?; + if a != u8::MAX { + write!(f, "{a:02x}")?; + } + Ok(()) + } else { + write!( + f, + "rgb({} {} {}{})", + percent(v.red), + percent(v.green), + percent(v.blue), + alpha(v.alpha) + ) + } + } + + /// Converts an f32 RGBA color to its 8-bit representation if the result is + /// [very close](is_very_close) to the original. + fn rgb_to_8_bit_lossless( + v: Rgb, + ) -> Option> { + let l = v.into_format::(); + let h = l.into_format::(); + (is_very_close(v.red, h.red) + && is_very_close(v.blue, h.blue) + && is_very_close(v.green, h.green) + && is_very_close(v.alpha, h.alpha)) + .then_some(l) + } + + fn linear_rgb(f: &mut fmt::Formatter<'_>, v: LinearRgb) -> fmt::Result { + write!( + f, + "color(srgb-linear {} {} {}{})", + percent(v.red), + percent(v.green), + percent(v.blue), + alpha(v.alpha), + ) + } + + fn hsl(f: &mut fmt::Formatter<'_>, v: Hsl) -> fmt::Result { + write!( + f, + "hsl({}deg {} {}{})", + number(v.hue.into_degrees()), + percent(v.saturation), + percent(v.lightness), + alpha(v.alpha), + ) + } + + /// Displays an alpha component if it not 1. + fn alpha(value: f32) -> impl Display { + typst_utils::display(move |f| { + if !is_very_close(value, 1.0) { + write!(f, " / {}", percent(value))?; + } + Ok(()) + }) + } + + /// Displays a rounded percentage. + /// + /// For a percentage, two significant digits after the comma gives us a + /// precision of 1/10_000, which is more than 12 bits (see `is_very_close`). + fn percent(ratio: f32) -> impl Display { + typst_utils::display(move |f| { + write!(f, "{}%", typst_utils::round_with_precision(ratio as f64 * 100.0, 2)) + }) + } + + /// Rounds a number for display. + /// + /// For a number between 0 and 1, four significant digits give us a + /// precision of 1/10_000, which is more than 12 bits (see `is_very_close`). + fn number(value: f32) -> impl Display { + typst_utils::round_with_precision(value as f64, 4) + } + + /// Whether two component values are close enough that there is no + /// difference when encoding them with 12-bit. 12 bit is the highest + /// reasonable color bit depth found in the industry. + fn is_very_close(a: f32, b: f32) -> bool { + const MAX_BIT_DEPTH: u32 = 12; + const EPS: f32 = 0.5 / 2_i32.pow(MAX_BIT_DEPTH) as f32; + (a - b).abs() < EPS + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tags_and_attr_const_internible() { + for elem in data::ELEMS { + let _ = HtmlTag::constant(elem.name); + } + for attr in data::ATTRS { + let _ = HtmlAttr::constant(attr.name); + } + } +} diff --git a/crates/typst-utils/src/pico.rs b/crates/typst-utils/src/pico.rs index 3aa4570f2..9f4a2fde7 100644 --- a/crates/typst-utils/src/pico.rs +++ b/crates/typst-utils/src/pico.rs @@ -204,18 +204,70 @@ mod exceptions { use std::cmp::Ordering; /// A global list of non-bitcode-encodable compile-time internible strings. + /// + /// Must be sorted. pub const LIST: &[&str] = &[ + "accept-charset", + "allowfullscreen", + "aria-activedescendant", + "aria-autocomplete", + "aria-colcount", + "aria-colindex", + "aria-controls", + "aria-describedby", + "aria-disabled", + "aria-dropeffect", + "aria-errormessage", + "aria-expanded", + "aria-haspopup", + "aria-keyshortcuts", + "aria-labelledby", + "aria-multiline", + "aria-multiselectable", + "aria-orientation", + "aria-placeholder", + "aria-posinset", + "aria-readonly", + "aria-relevant", + "aria-required", + "aria-roledescription", + "aria-rowcount", + "aria-rowindex", + "aria-selected", + "aria-valuemax", + "aria-valuemin", + "aria-valuenow", + "aria-valuetext", + "autocapitalize", "cjk-latin-spacing", + "contenteditable", "discretionary-ligatures", + "fetchpriority", + "formnovalidate", "h5", "h6", "historical-ligatures", "number-clearance", "number-margin", "numbering-scope", + "onbeforeprint", + "onbeforeunload", + "onlanguagechange", + "onmessageerror", + "onrejectionhandled", + "onunhandledrejection", "page-numbering", "par-line-marker", + "popovertarget", + "popovertargetaction", + "referrerpolicy", + "shadowrootclonable", + "shadowrootcustomelementregistry", + "shadowrootdelegatesfocus", + "shadowrootmode", + "shadowrootserializable", "transparentize", + "writingsuggestions", ]; /// Try to find the index of an exception if it exists. diff --git a/tests/ref/html/html-typed.html b/tests/ref/html/html-typed.html new file mode 100644 index 000000000..ef62538fe --- /dev/null +++ b/tests/ref/html/html-typed.html @@ -0,0 +1,63 @@ + + + + + + + +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+ + + + + + + + + +
+
+
+
+
+
+
+
+
RTL
+ My wonderful image +
+
+ +
+
+
+
+
+
+ + + diff --git a/tests/suite/html/typed.typ b/tests/suite/html/typed.typ new file mode 100644 index 000000000..e8fa9f6e7 --- /dev/null +++ b/tests/suite/html/typed.typ @@ -0,0 +1,187 @@ +--- html-typed html --- +// String +#html.div(id: "hi") + +// Different kinds of options. +#html.div(aria-autocomplete: none) // "none" +#html.div(aria-expanded: none) // "undefined" +#html.link(referrerpolicy: none) // present + +// Different kinds of bools. +#html.div(autofocus: false) // absent +#html.div(autofocus: true) // present +#html.div(hidden: false) // absent +#html.div(hidden: true) // present +#html.div(aria-atomic: false) // "false" +#html.div(aria-atomic: true) // "true" +#html.div(translate: false) // "no" +#html.div(translate: true) // "yes" +#html.form(autocomplete: false) // "on" +#html.form(autocomplete: true) // "off" + +// Char +#html.div(accesskey: "K") + +// Int +#html.div(aria-colcount: 2) +#html.object(width: 120, height: 10) +#html.td(rowspan: 2) + +// Float +#html.meter(low: 3.4, high: 7.9) + +// Space-separated strings. +#html.div(class: "alpha") +#html.div(class: "alpha beta") +#html.div(class: ("alpha", "beta")) + +// Comma-separated strings. +#html.div(html.input(accept: "image/jpeg")) +#html.div(html.input(accept: "image/jpeg, image/png")) +#html.div(html.input(accept: ("image/jpeg", "image/png"))) + +// Comma-separated floats. +#html.area(coords: (2.3, 4, 5.6)) + +// Colors. +#for c in ( + red, + red.lighten(10%), + luma(50%), + cmyk(10%, 20%, 30%, 40%), + oklab(27%, 20%, -3%, 50%), + color.linear-rgb(20%, 30%, 40%, 50%), + color.hsl(20deg, 10%, 20%), + color.hsv(30deg, 20%, 30%), +) { + html.link(color: c) +} + +// Durations & datetimes. +#for d in ( + duration(weeks: 3, seconds: 4), + duration(days: 1, minutes: 4), + duration(), + datetime(day: 10, month: 7, year: 2005), + datetime(day: 1, month: 2, year: 0), + datetime(hour: 6, minute: 30, second: 0), + datetime(day: 1, month: 2, year: 0, hour: 11, minute: 11, second: 0), + datetime(day: 1, month: 2, year: 0, hour: 6, minute: 0, second: 9), +) { + html.div(html.time(datetime: d)) +} + +// Direction +#html.div(dir: ltr)[RTL] + +// Image candidate and source size. +#html.img( + src: "image.png", + alt: "My wonderful image", + srcset: ( + (src: "/image-120px.png", width: 120), + (src: "/image-60px.png", width: 60), + ), + sizes: ( + (condition: "min-width: 800px", size: 400pt), + (condition: "min-width: 400px", size: 250pt), + ) +) + +// String enum. +#html.form(enctype: "text/plain") +#html.form(role: "complementary") +#html.div(hidden: "until-found") + +// Or. +#html.div(aria-checked: false) +#html.div(aria-checked: true) +#html.div(aria-checked: "mixed") + +// Input value. +#html.div(html.input(value: 5.6)) +#html.div(html.input(value: red)) +#html.div(html.input(min: 3, max: 9)) + +// Icon size. +#html.link(rel: "icon", sizes: ((32, 24), (64, 48))) + +--- html-typed-dir-str html --- +// Error: 16-21 expected direction or auto, found string +#html.div(dir: "ltr") + +--- html-typed-char-too-long html --- +// Error: 22-35 expected exactly one character +#html.div(accesskey: ("Ctrl", "K")) + +--- html-typed-int-negative html --- +// Error: 18-21 number must be at least zero +#html.img(width: -10) + +--- html-typed-int-zero html --- +// Error: 22-23 number must be positive +#html.textarea(rows: 0) + +--- html-typed-float-negative html --- +// Error: 19-23 number must be positive +#html.input(step: -3.4) + +--- html-typed-string-array-with-space html --- +// Error: 18-41 array item may not contain a space +// Hint: 18-41 the array attribute will be encoded as a space-separated string +#html.div(class: ("alpha beta", "gamma")) + +--- html-typed-float-array-invalid-shorthand html --- +// Error: 20-23 expected array, found float +#html.area(coords: 4.5) + +--- html-typed-dir-vertical html --- +// Error: 16-19 direction must be horizontal +#html.div(dir: ttb) + +--- html-typed-string-enum-invalid html --- +// Error: 21-28 expected "application/x-www-form-urlencoded", "multipart/form-data", or "text/plain" +#html.form(enctype: "utf-8") + +--- html-typed-or-invalid --- +// Error: 25-31 expected boolean or "mixed" +#html.div(aria-checked: "nope") + +--- html-typed-string-enum-or-array-invalid --- +// Error: 27-33 expected array, "additions", "additions text", "all", "removals", or "text" +// Error: 49-54 expected boolean or "mixed" +#html.link(aria-relevant: "nope", aria-checked: "yes") + +--- html-typed-srcset-both-width-and-density html --- +// Error: 19-64 cannot specify both `width` and `density` +#html.img(srcset: ((src: "img.png", width: 120, density: 0.5),)) + +--- html-typed-srcset-src-comma html --- +// Error: 19-50 `src` must not start or end with a comma +#html.img(srcset: ((src: "img.png,", width: 50),)) + +--- html-typed-sizes-string-size html --- +// Error: 18-66 expected length, found string +// Hint: 18-66 CSS lengths that are not expressible as Typst lengths are not yet supported +// Hint: 18-66 you can use `html.elem` to create a raw attribute +#html.img(sizes: ((condition: "min-width: 100px", size: "10px"),)) + +--- html-typed-input-value-invalid html --- +// Error: 20-25 expected string, float, datetime, color, or array, found boolean +#html.input(value: false) + +--- html-typed-input-bound-invalid html --- +// Error: 18-21 expected string, float, or datetime, found color +#html.input(min: red) + +--- html-typed-icon-size-invalid html --- +// Error: 32-45 expected array, found string +#html.link(rel: "icon", sizes: "10x20 20x30") + +--- html-typed-hidden-none html --- +// Error: 19-23 expected boolean or "until-found", found none +#html.div(hidden: none) + +--- html-typed-invalid-body html --- +// Error: 10-14 unexpected argument +#html.img[hi] From fbb02f40d96e0a9d41d19a575fca4d8e9c344119 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 23 Jun 2025 14:18:41 +0200 Subject: [PATCH 016/101] Consistent codepoint formatting in HTML and PDF error messages --- crates/typst-html/src/encode.rs | 2 +- crates/typst-pdf/src/convert.rs | 17 ++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index c6a6a7bce..eb25ab1ec 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -165,7 +165,7 @@ fn write_escape(w: &mut Writer, c: char) -> StrResult<()> { c if charsets::is_w3c_text_char(c) && c != '\r' => { write!(w.buf, "&#x{:x};", c as u32).unwrap() } - _ => bail!("the character {} cannot be encoded in HTML", c.repr()), + _ => bail!("the character `{}` cannot be encoded in HTML", c.repr()), } Ok(()) } diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index f5ca31730..645d56f11 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -13,7 +13,7 @@ use krilla::surface::Surface; use krilla::{Document, SerializeSettings}; use krilla_svg::render_svg_glyph; use typst_library::diag::{bail, error, SourceDiagnostic, SourceResult}; -use typst_library::foundations::NativeElement; +use typst_library::foundations::{NativeElement, Repr}; use typst_library::introspection::Location; use typst_library::layout::{ Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform, @@ -429,14 +429,18 @@ fn convert_error( display_font(gc.fonts_backward.get(f).unwrap()); hint: "try using a different font" ), - ValidationError::InvalidCodepointMapping(_, _, cp, loc) => { - if let Some(c) = cp.map(|c| eco_format!("{:#06x}", c as u32)) { + ValidationError::InvalidCodepointMapping(_, _, c, loc) => { + if let Some(c) = c { let msg = if loc.is_some() { "the PDF contains text with" } else { "the text contains" }; - error!(to_span(*loc), "{prefix} {msg} the disallowed codepoint {c}") + error!( + to_span(*loc), + "{prefix} {msg} the disallowed codepoint `{}`", + c.repr() + ) } else { // I think this code path is in theory unreachable, // but just to be safe. @@ -454,13 +458,12 @@ fn convert_error( } } ValidationError::UnicodePrivateArea(_, _, c, loc) => { - let code_point = eco_format!("{:#06x}", *c as u32); let msg = if loc.is_some() { "the PDF" } else { "the text" }; error!( to_span(*loc), - "{prefix} {msg} contains the codepoint {code_point}"; + "{prefix} {msg} contains the codepoint `{}`", c.repr(); hint: "codepoints from the Unicode private area are \ - forbidden in this export mode" + forbidden in this export mode", ) } ValidationError::Transparency(loc) => { From c1b2aee1a941f49d5eb8c04c9b19841dbeb1b27d Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 23 Jun 2025 14:21:35 +0200 Subject: [PATCH 017/101] Test runner support for HTML export errors --- tests/src/run.rs | 45 +++++++++++++++++++++++-------------- tests/suite/html/syntax.typ | 3 +++ 2 files changed, 31 insertions(+), 17 deletions(-) create mode 100644 tests/suite/html/syntax.typ diff --git a/tests/src/run.rs b/tests/src/run.rs index 76ce1299f..1d93ba392 100644 --- a/tests/src/run.rs +++ b/tests/src/run.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use ecow::eco_vec; use tiny_skia as sk; -use typst::diag::{SourceDiagnostic, Warned}; +use typst::diag::{SourceDiagnostic, SourceResult, Warned}; use typst::html::HtmlDocument; use typst::layout::{Abs, Frame, FrameItem, PagedDocument, Transform}; use typst::visualize::Color; @@ -82,17 +82,26 @@ impl<'a> Runner<'a> { /// Run test specific to document format. fn run_test(&mut self) { let Warned { output, warnings } = typst::compile(&self.world); - let (doc, errors) = match output { + let (doc, mut errors) = match output { Ok(doc) => (Some(doc), eco_vec![]), Err(errors) => (None, errors), }; - if doc.is_none() && errors.is_empty() { + D::check_custom(self, doc.as_ref()); + + let output = doc.and_then(|doc: D| match doc.make_live() { + Ok(live) => Some((doc, live)), + Err(list) => { + errors.extend(list); + None + } + }); + + if output.is_none() && errors.is_empty() { log!(self, "no document, but also no errors"); } - D::check_custom(self, doc.as_ref()); - self.check_output(doc.as_ref()); + self.check_output(output); for error in &errors { self.check_diagnostic(NoteKind::Error, error); @@ -128,12 +137,12 @@ impl<'a> Runner<'a> { } /// Check that the document output is correct. - fn check_output(&mut self, document: Option<&D>) { + fn check_output(&mut self, output: Option<(D, D::Live)>) { let live_path = D::live_path(&self.test.name); let ref_path = D::ref_path(&self.test.name); let ref_data = std::fs::read(&ref_path); - let Some(document) = document else { + let Some((document, live)) = output else { if ref_data.is_ok() { log!(self, "missing document"); log!(self, " ref | {}", ref_path.display()); @@ -141,7 +150,7 @@ impl<'a> Runner<'a> { return; }; - let skippable = match D::is_skippable(document) { + let skippable = match D::is_skippable(&document) { Ok(skippable) => skippable, Err(()) => { log!(self, "document has zero pages"); @@ -157,7 +166,6 @@ impl<'a> Runner<'a> { } // Render and save live version. - let live = document.make_live(); document.save_live(&self.test.name, &live); // Compare against reference output if available. @@ -214,9 +222,13 @@ impl<'a> Runner<'a> { return; } - let message = diag.message.replace("\\", "/"); + let message = if diag.message.contains("\\u{") { + &diag.message + } else { + &diag.message.replace("\\", "/") + }; let range = self.world.range(diag.span); - self.validate_note(kind, diag.span.id(), range.clone(), &message); + self.validate_note(kind, diag.span.id(), range.clone(), message); // Check hints. for hint in &diag.hints { @@ -359,7 +371,7 @@ trait OutputType: Document { } /// Produces the live output. - fn make_live(&self) -> Self::Live; + fn make_live(&self) -> SourceResult; /// Saves the live output. fn save_live(&self, name: &str, live: &Self::Live); @@ -406,8 +418,8 @@ impl OutputType for PagedDocument { } } - fn make_live(&self) -> Self::Live { - render(self, 1.0) + fn make_live(&self) -> SourceResult { + Ok(render(self, 1.0)) } fn save_live(&self, name: &str, live: &Self::Live) { @@ -471,9 +483,8 @@ impl OutputType for HtmlDocument { format!("{}/html/{}.html", crate::REF_PATH, name).into() } - fn make_live(&self) -> Self::Live { - // TODO: Do this earlier to be able to process export errors. - typst_html::html(self).unwrap() + fn make_live(&self) -> SourceResult { + typst_html::html(self) } fn save_live(&self, name: &str, live: &Self::Live) { diff --git a/tests/suite/html/syntax.typ b/tests/suite/html/syntax.typ new file mode 100644 index 000000000..af671ef58 --- /dev/null +++ b/tests/suite/html/syntax.typ @@ -0,0 +1,3 @@ +--- html-non-char html --- +// Error: 1-9 the character `"\u{fdd0}"` cannot be encoded in HTML +\u{fdd0} From 9050ee1639a20463e3cafce58964c9ef0fa38205 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 23 Jun 2025 14:22:09 +0200 Subject: [PATCH 018/101] Turn non-empty void element into export error --- crates/typst-html/src/encode.rs | 3 +++ crates/typst-html/src/lib.rs | 3 --- tests/suite/html/syntax.typ | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index eb25ab1ec..2bfa78a72 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -89,6 +89,9 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { w.buf.push('>'); if tag::is_void(element.tag) { + if !element.children.is_empty() { + bail!(element.span, "HTML void elements must not have children"); + } return Ok(()); } diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index 7d78a5da4..703948f66 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -180,9 +180,6 @@ fn handle( if let Some(body) = elem.body(styles) { children = html_fragment(engine, body, locator.next(&elem.span()), styles)?; } - if tag::is_void(elem.tag) && !children.is_empty() { - bail!(elem.span(), "HTML void elements may not have children"); - } let element = HtmlElement { tag: elem.tag, attrs: elem.attrs(styles).clone(), diff --git a/tests/suite/html/syntax.typ b/tests/suite/html/syntax.typ index af671ef58..c95fa06ed 100644 --- a/tests/suite/html/syntax.typ +++ b/tests/suite/html/syntax.typ @@ -1,3 +1,7 @@ --- html-non-char html --- // Error: 1-9 the character `"\u{fdd0}"` cannot be encoded in HTML \u{fdd0} + +--- html-void-element-with-children html --- +// Error: 2-27 HTML void elements must not have children +#html.elem("img", [Hello]) From f8dc1ad3bdbe20ec25379427e6afba36c75ec08c Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 23 Jun 2025 14:30:22 +0200 Subject: [PATCH 019/101] Handle pre elements that start with a newline --- crates/typst-html/src/encode.rs | 17 +++++++++++++++++ .../html/html-pre-starting-with-newline.html | 17 +++++++++++++++++ tests/suite/html/syntax.typ | 5 +++++ 3 files changed, 39 insertions(+) create mode 100644 tests/ref/html/html-pre-starting-with-newline.html diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 2bfa78a72..82a5df47c 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -97,6 +97,11 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { let pretty = w.pretty; if !element.children.is_empty() { + // See HTML spec § 13.1.2.5. + if element.tag == tag::pre && starts_with_newline(element) { + w.buf.push('\n'); + } + let pretty_inside = allows_pretty_inside(element.tag) && element.children.iter().any(|node| match node { HtmlNode::Element(child) => wants_pretty_around(child.tag), @@ -133,6 +138,18 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { Ok(()) } +/// Whether the first character in the element is a newline. +fn starts_with_newline(element: &HtmlElement) -> bool { + for child in &element.children { + match child { + HtmlNode::Tag(_) => {} + HtmlNode::Text(text, _) => return text.starts_with(['\n', '\r']), + _ => return false, + } + } + false +} + /// Whether we are allowed to add an extra newline at the start and end of the /// element's contents. /// diff --git a/tests/ref/html/html-pre-starting-with-newline.html b/tests/ref/html/html-pre-starting-with-newline.html new file mode 100644 index 000000000..676d1a803 --- /dev/null +++ b/tests/ref/html/html-pre-starting-with-newline.html @@ -0,0 +1,17 @@ + + + + + + + +
hello
+
+
+hello
+
+
+
+hello
+ + diff --git a/tests/suite/html/syntax.typ b/tests/suite/html/syntax.typ index c95fa06ed..fb5caf3bd 100644 --- a/tests/suite/html/syntax.typ +++ b/tests/suite/html/syntax.typ @@ -5,3 +5,8 @@ --- html-void-element-with-children html --- // Error: 2-27 HTML void elements must not have children #html.elem("img", [Hello]) + +--- html-pre-starting-with-newline html --- +#html.pre("hello") +#html.pre("\nhello") +#html.pre("\n\nhello") From c2e2fd99f69665e2361a1129dd04121a5b2c61a2 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 23 Jun 2025 14:35:52 +0200 Subject: [PATCH 020/101] Extract `write_children` function --- crates/typst-html/src/encode.rs | 81 ++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 37 deletions(-) diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 82a5df47c..758bf0b91 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -28,7 +28,7 @@ struct Writer { pretty: bool, } -/// Write a newline and indent, if pretty printing is enabled. +/// Writes a newline and indent, if pretty printing is enabled. fn write_indent(w: &mut Writer) { if w.pretty { w.buf.push('\n'); @@ -38,7 +38,7 @@ fn write_indent(w: &mut Writer) { } } -/// Encode an HTML node into the writer. +/// Encodes an HTML node into the writer. fn write_node(w: &mut Writer, node: &HtmlNode) -> SourceResult<()> { match node { HtmlNode::Tag(_) => {} @@ -49,7 +49,7 @@ fn write_node(w: &mut Writer, node: &HtmlNode) -> SourceResult<()> { Ok(()) } -/// Encode plain text into the writer. +/// Encodes plain text into the writer. fn write_text(w: &mut Writer, text: &str, span: Span) -> SourceResult<()> { for c in text.chars() { if charsets::is_valid_in_normal_element_text(c) { @@ -61,7 +61,7 @@ fn write_text(w: &mut Writer, text: &str, span: Span) -> SourceResult<()> { Ok(()) } -/// Encode one element into the write. +/// Encodes one element into the writer. fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { w.buf.push('<'); w.buf.push_str(&element.tag.resolve()); @@ -95,41 +95,9 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { return Ok(()); } - let pretty = w.pretty; if !element.children.is_empty() { - // See HTML spec § 13.1.2.5. - if element.tag == tag::pre && starts_with_newline(element) { - w.buf.push('\n'); - } - - let pretty_inside = allows_pretty_inside(element.tag) - && element.children.iter().any(|node| match node { - HtmlNode::Element(child) => wants_pretty_around(child.tag), - _ => false, - }); - - w.pretty &= pretty_inside; - let mut indent = w.pretty; - - w.level += 1; - for c in &element.children { - let pretty_around = match c { - HtmlNode::Tag(_) => continue, - HtmlNode::Element(child) => w.pretty && wants_pretty_around(child.tag), - HtmlNode::Text(..) | HtmlNode::Frame(_) => false, - }; - - if core::mem::take(&mut indent) || pretty_around { - write_indent(w); - } - write_node(w, c)?; - indent = pretty_around; - } - w.level -= 1; - - write_indent(w); + write_children(w, element)?; } - w.pretty = pretty; w.buf.push_str(" SourceResult<()> { Ok(()) } +/// Encodes the children of an element. +fn write_children(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { + // See HTML spec § 13.1.2.5. + if element.tag == tag::pre && starts_with_newline(element) { + w.buf.push('\n'); + } + + let pretty = w.pretty; + let pretty_inside = allows_pretty_inside(element.tag) + && element.children.iter().any(|node| match node { + HtmlNode::Element(child) => wants_pretty_around(child.tag), + _ => false, + }); + + w.pretty &= pretty_inside; + let mut indent = w.pretty; + + w.level += 1; + for c in &element.children { + let pretty_around = match c { + HtmlNode::Tag(_) => continue, + HtmlNode::Element(child) => w.pretty && wants_pretty_around(child.tag), + HtmlNode::Text(..) | HtmlNode::Frame(_) => false, + }; + + if core::mem::take(&mut indent) || pretty_around { + write_indent(w); + } + write_node(w, c)?; + indent = pretty_around; + } + w.level -= 1; + + write_indent(w); + w.pretty = pretty; + + Ok(()) +} + /// Whether the first character in the element is a newline. fn starts_with_newline(element: &HtmlElement) -> bool { for child in &element.children { From bf8ef2a4a5ffa9c30fce9fc254ffcf982634e4c6 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 23 Jun 2025 15:54:52 +0200 Subject: [PATCH 021/101] Properly handle raw text elements --- crates/typst-html/src/encode.rs | 110 +++++++++++++++++- ...capable-raw-text-contains-closing-tag.html | 8 ++ tests/ref/html/html-script.html | 21 ++++ tests/ref/html/html-style.html | 14 +++ tests/suite/html/syntax.typ | 51 ++++++++ 5 files changed, 202 insertions(+), 2 deletions(-) create mode 100644 tests/ref/html/html-escapable-raw-text-contains-closing-tag.html create mode 100644 tests/ref/html/html-script.html create mode 100644 tests/ref/html/html-style.html diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 758bf0b91..adcb6e032 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -2,7 +2,9 @@ use std::fmt::Write; use typst_library::diag::{bail, At, SourceResult, StrResult}; use typst_library::foundations::Repr; -use typst_library::html::{charsets, tag, HtmlDocument, HtmlElement, HtmlNode, HtmlTag}; +use typst_library::html::{ + attr, charsets, tag, HtmlDocument, HtmlElement, HtmlNode, HtmlTag, +}; use typst_library::layout::Frame; use typst_syntax::Span; @@ -95,7 +97,9 @@ fn write_element(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { return Ok(()); } - if !element.children.is_empty() { + if tag::is_raw(element.tag) { + write_raw(w, element)?; + } else if !element.children.is_empty() { write_children(w, element)?; } @@ -157,6 +161,108 @@ fn starts_with_newline(element: &HtmlElement) -> bool { false } +/// Encodes the contents of a raw text element. +fn write_raw(w: &mut Writer, element: &HtmlElement) -> SourceResult<()> { + let text = collect_raw_text(element)?; + + if let Some(closing) = find_closing_tag(&text, element.tag) { + bail!( + element.span, + "HTML raw text element cannot contain its own closing tag"; + hint: "the sequence `{closing}` appears in the raw text", + ) + } + + let mode = if w.pretty { RawMode::of(element, &text) } else { RawMode::Keep }; + match mode { + RawMode::Keep => { + w.buf.push_str(&text); + } + RawMode::Wrap => { + w.buf.push('\n'); + w.buf.push_str(&text); + write_indent(w); + } + RawMode::Indent => { + w.level += 1; + for line in text.lines() { + write_indent(w); + w.buf.push_str(line); + } + w.level -= 1; + write_indent(w); + } + } + + Ok(()) +} + +/// Collects the textual contents of a raw text element. +fn collect_raw_text(element: &HtmlElement) -> SourceResult { + let mut output = String::new(); + for c in &element.children { + match c { + HtmlNode::Tag(_) => continue, + HtmlNode::Text(text, _) => output.push_str(text), + HtmlNode::Element(_) | HtmlNode::Frame(_) => { + let span = match c { + HtmlNode::Element(child) => child.span, + _ => element.span, + }; + bail!(span, "HTML raw text element cannot have non-text children") + } + }; + } + Ok(output) +} + +/// Finds a closing sequence for the given tag in the text, if it exists. +/// +/// See HTML spec § 13.1.2.6. +fn find_closing_tag(text: &str, tag: HtmlTag) -> Option<&str> { + let s = tag.resolve(); + let len = s.len(); + text.match_indices("= len + && rest[..len].eq_ignore_ascii_case(&s) + && rest[len..].starts_with(['\t', '\n', '\u{c}', '\r', ' ', '>', '/']); + disallowed.then(|| &text[i..i + 2 + len]) + }) +} + +/// How to format the contents of a raw text element. +enum RawMode { + /// Just don't touch it. + Keep, + /// Newline after the opening and newline + indent before the closing tag. + Wrap, + /// Newlines after opening and before closing tag and each line indented. + Indent, +} + +impl RawMode { + fn of(element: &HtmlElement, text: &str) -> Self { + match element.tag { + tag::script + if !element.attrs.0.iter().any(|(attr, value)| { + *attr == attr::r#type && value != "text/javascript" + }) => + { + // Template literals can be multi-line, so indent may change + // the semantics of the JavaScript. + if text.contains('`') { + Self::Wrap + } else { + Self::Indent + } + } + tag::style => Self::Indent, + _ => Self::Keep, + } + } +} + /// Whether we are allowed to add an extra newline at the start and end of the /// element's contents. /// diff --git a/tests/ref/html/html-escapable-raw-text-contains-closing-tag.html b/tests/ref/html/html-escapable-raw-text-contains-closing-tag.html new file mode 100644 index 000000000..9e0b96433 --- /dev/null +++ b/tests/ref/html/html-escapable-raw-text-contains-closing-tag.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/tests/ref/html/html-script.html b/tests/ref/html/html-script.html new file mode 100644 index 000000000..81b74765a --- /dev/null +++ b/tests/ref/html/html-script.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/tests/ref/html/html-style.html b/tests/ref/html/html-style.html new file mode 100644 index 000000000..c8d558bce --- /dev/null +++ b/tests/ref/html/html-style.html @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/tests/suite/html/syntax.typ b/tests/suite/html/syntax.typ index fb5caf3bd..eb1c86994 100644 --- a/tests/suite/html/syntax.typ +++ b/tests/suite/html/syntax.typ @@ -10,3 +10,54 @@ #html.pre("hello") #html.pre("\nhello") #html.pre("\n\nhello") + +--- html-script html --- +// This should be pretty and indented. +#html.script( + ```js + const x = 1 + const y = 2 + console.log(x < y, Math.max(1, 2)) + ```.text, +) + +// This should have extra newlines, but no indent because of the multiline +// string literal. +#html.script("console.log(`Hello\nWorld`)") + +// This should be untouched. +#html.script( + type: "text/python", + ```py + x = 1 + y = 2 + print(x < y, max(x, y)) + ```.text, +) + +--- html-style html --- +// This should be pretty and indented. +#html.style( + ```css + body { + text: red; + } + ```.text, +) + +--- html-raw-text-contains-elem html --- +// Error: 14-32 HTML raw text element cannot have non-text children +#html.script(html.strong[Hello]) + +--- html-raw-text-contains-frame html --- +// Error: 2-29 HTML raw text element cannot have non-text children +#html.script(html.frame[Ok]) + +--- html-raw-text-contains-closing-tag html --- +// Error: 2-32 HTML raw text element cannot contain its own closing tag +// Hint: 2-32 the sequence `") From 38dd6da237b8d1ea86f82069338d9ceae479d180 Mon Sep 17 00:00:00 2001 From: Wannes Malfait <46323945+WannesMalfait@users.noreply.github.com> Date: Mon, 23 Jun 2025 16:58:04 +0200 Subject: [PATCH 022/101] Fix stroke cap of shapes with partial stroke (#5688) --- crates/typst-layout/src/shapes.rs | 113 +++++++++++++++++++++++++++--- tests/ref/rect-stroke-caps.png | Bin 0 -> 252 bytes tests/suite/visualize/rect.typ | 16 +++++ 3 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 tests/ref/rect-stroke-caps.png diff --git a/crates/typst-layout/src/shapes.rs b/crates/typst-layout/src/shapes.rs index 7ab41e9d4..0616b4ce4 100644 --- a/crates/typst-layout/src/shapes.rs +++ b/crates/typst-layout/src/shapes.rs @@ -11,8 +11,8 @@ use typst_library::layout::{ }; use typst_library::visualize::{ CircleElem, CloseMode, Curve, CurveComponent, CurveElem, EllipseElem, FillRule, - FixedStroke, Geometry, LineElem, Paint, PathElem, PathVertex, PolygonElem, RectElem, - Shape, SquareElem, Stroke, + FixedStroke, Geometry, LineCap, LineElem, Paint, PathElem, PathVertex, PolygonElem, + RectElem, Shape, SquareElem, Stroke, }; use typst_syntax::Span; use typst_utils::{Get, Numeric}; @@ -889,7 +889,13 @@ fn segmented_rect( let end = current; last = current; let Some(stroke) = strokes.get_ref(start.side_cw()) else { continue }; - let (shape, ontop) = segment(start, end, &corners, stroke); + let start_cap = stroke.cap; + let end_cap = match strokes.get_ref(end.side_ccw()) { + Some(stroke) => stroke.cap, + None => start_cap, + }; + let (shape, ontop) = + segment(start, end, start_cap, end_cap, &corners, stroke); if ontop { res.push(shape); } else { @@ -899,7 +905,14 @@ fn segmented_rect( } } else if let Some(stroke) = &strokes.top { // single segment - let (shape, _) = segment(Corner::TopLeft, Corner::TopLeft, &corners, stroke); + let (shape, _) = segment( + Corner::TopLeft, + Corner::TopLeft, + stroke.cap, + stroke.cap, + &corners, + stroke, + ); res.push(shape); } res @@ -946,6 +959,8 @@ fn curve_segment( fn segment( start: Corner, end: Corner, + start_cap: LineCap, + end_cap: LineCap, corners: &Corners, stroke: &FixedStroke, ) -> (Shape, bool) { @@ -979,7 +994,7 @@ fn segment( let use_fill = solid && fill_corners(start, end, corners); let shape = if use_fill { - fill_segment(start, end, corners, stroke) + fill_segment(start, end, start_cap, end_cap, corners, stroke) } else { stroke_segment(start, end, corners, stroke.clone()) }; @@ -1010,6 +1025,8 @@ fn stroke_segment( fn fill_segment( start: Corner, end: Corner, + start_cap: LineCap, + end_cap: LineCap, corners: &Corners, stroke: &FixedStroke, ) -> Shape { @@ -1035,8 +1052,7 @@ fn fill_segment( if c.arc_outer() { curve.arc_line(c.mid_outer(), c.center_outer(), c.end_outer()); } else { - curve.line(c.outer()); - curve.line(c.end_outer()); + c.start_cap(&mut curve, start_cap); } } @@ -1079,7 +1095,7 @@ fn fill_segment( if c.arc_inner() { curve.arc_line(c.mid_inner(), c.center_inner(), c.start_inner()); } else { - curve.line(c.center_inner()); + c.end_cap(&mut curve, end_cap); } } @@ -1134,6 +1150,16 @@ struct ControlPoints { } impl ControlPoints { + /// Rotate point around the origin, relative to the top-left. + fn rotate_centered(&self, point: Point) -> Point { + match self.corner { + Corner::TopLeft => point, + Corner::TopRight => Point { x: -point.y, y: point.x }, + Corner::BottomRight => Point { x: -point.x, y: -point.y }, + Corner::BottomLeft => Point { x: point.y, y: -point.x }, + } + } + /// Move and rotate the point from top-left to the required corner. fn rotate(&self, point: Point) -> Point { match self.corner { @@ -1280,6 +1306,77 @@ impl ControlPoints { y: self.stroke_after, }) } + + /// Draw the cap at the beginning of the segment. + /// + /// If this corner has a stroke before it, + /// a default "butt" cap is used. + /// + /// NOTE: doesn't support the case where the corner has a radius. + pub fn start_cap(&self, curve: &mut Curve, cap_type: LineCap) { + if self.stroke_before != Abs::zero() + || self.radius != Abs::zero() + || cap_type == LineCap::Butt + { + // Just the default cap. + curve.line(self.outer()); + } else if cap_type == LineCap::Square { + // Extend by the stroke width. + let offset = + self.rotate_centered(Point { x: -self.stroke_after, y: Abs::zero() }); + curve.line(self.end_inner() + offset); + curve.line(self.outer() + offset); + } else if cap_type == LineCap::Round { + // We push the center by a little bit to ensure the correct + // half of the circle gets drawn. If it is perfectly centered + // the `arc` function just degenerates into a line, which we + // do not want in this case. + curve.arc( + self.end_inner(), + (self.end_inner() + + self.rotate_centered(Point { x: Abs::raw(1.0), y: Abs::zero() }) + + self.outer()) + / 2., + self.outer(), + ); + } + curve.line(self.end_outer()); + } + + /// Draw the cap at the end of the segment. + /// + /// If this corner has a stroke before it, + /// a default "butt" cap is used. + /// + /// NOTE: doesn't support the case where the corner has a radius. + pub fn end_cap(&self, curve: &mut Curve, cap_type: LineCap) { + if self.stroke_after != Abs::zero() + || self.radius != Abs::zero() + || cap_type == LineCap::Butt + { + // Just the default cap. + curve.line(self.center_inner()); + } else if cap_type == LineCap::Square { + // Extend by the stroke width. + let offset = + self.rotate_centered(Point { x: Abs::zero(), y: -self.stroke_before }); + curve.line(self.outer() + offset); + curve.line(self.center_inner() + offset); + } else if cap_type == LineCap::Round { + // We push the center by a little bit to ensure the correct + // half of the circle gets drawn. If it is perfectly centered + // the `arc` function just degenerates into a line, which we + // do not want in this case. + curve.arc( + self.outer(), + (self.outer() + + self.rotate_centered(Point { x: Abs::zero(), y: Abs::raw(1.0) }) + + self.center_inner()) + / 2., + self.center_inner(), + ); + } + } } /// Helper to draw arcs with Bézier curves. diff --git a/tests/ref/rect-stroke-caps.png b/tests/ref/rect-stroke-caps.png new file mode 100644 index 0000000000000000000000000000000000000000..13a34ad9aaf255c1a8f758c93842dd88cdb850ff GIT binary patch literal 252 zcmeAS@N?(olHy`uVBq!ia0vp^6+oQ90VEjYZ)Q&gQinZV978H@y}j-zc*sG(>iSw3$>=}Dp&?udk6iOuDG73+bd*Hz3W{yHpf3r%RKw) z&_(ZBlLmFqUC$1=8YV!&gW$fsFP7*@9q{%E{ Date: Mon, 23 Jun 2025 17:09:03 +0200 Subject: [PATCH 023/101] Adding Croatian translations entries (#6413) --- crates/typst-library/src/text/lang.rs | 1 + crates/typst-library/translations/hr.txt | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 crates/typst-library/translations/hr.txt diff --git a/crates/typst-library/src/text/lang.rs b/crates/typst-library/src/text/lang.rs index e06156c43..a170714b5 100644 --- a/crates/typst-library/src/text/lang.rs +++ b/crates/typst-library/src/text/lang.rs @@ -30,6 +30,7 @@ const TRANSLATIONS: &[(&str, &str)] = &[ translation!("fr"), translation!("gl"), translation!("he"), + translation!("hr"), translation!("hu"), translation!("id"), translation!("is"), diff --git a/crates/typst-library/translations/hr.txt b/crates/typst-library/translations/hr.txt new file mode 100644 index 000000000..ea0754592 --- /dev/null +++ b/crates/typst-library/translations/hr.txt @@ -0,0 +1,8 @@ +figure = Slika +table = Tablica +equation = Jednadžba +bibliography = Literatura +heading = Odjeljak +outline = Sadržaj +raw = Kôd +page = str. From 24293a6c121a4b4e02c32901fec44e0093aa5d8c Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 24 Jun 2025 12:56:58 +0300 Subject: [PATCH 024/101] Rewrite `outline.indent` example (#6383) Co-authored-by: Laurenz --- crates/typst-library/src/model/outline.rs | 35 +++++++++++------------ 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index 489c375e6..16a116146 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -225,25 +225,21 @@ pub struct OutlineElem { /// to just specifying `{2em}`. /// /// ```example - /// #set heading(numbering: "1.a.") + /// >>> #show heading: none + /// #set heading(numbering: "I-I.") + /// #set outline(title: none) /// - /// #outline( - /// title: [Contents (Automatic)], - /// indent: auto, - /// ) + /// #outline() + /// #line(length: 100%) + /// #outline(indent: 3em) /// - /// #outline( - /// title: [Contents (Length)], - /// indent: 2em, - /// ) - /// - /// = About ACME Corp. - /// == History - /// === Origins - /// #lorem(10) - /// - /// == Products - /// #lorem(10) + /// = Software engineering technologies + /// == Requirements + /// == Tools and technologies + /// === Code editors + /// == Analyzing alternatives + /// = Designing software components + /// = Testing and integration /// ``` pub indent: Smart, } @@ -450,8 +446,9 @@ impl OutlineEntry { /// at the same level are aligned. /// /// If the outline's indent is a fixed value or a function, the prefixes are - /// indented, but the inner contents are simply inset from the prefix by the - /// specified `gap`, rather than aligning outline-wide. + /// indented, but the inner contents are simply offset from the prefix by + /// the specified `gap`, rather than aligning outline-wide. For a visual + /// explanation, see [`outline.indent`]($outline.indent). #[func(contextual)] pub fn indented( &self, From 899de6d5d501c6aed04897d425dd3615e745743e Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 24 Jun 2025 10:03:10 +0000 Subject: [PATCH 025/101] Use ICU data to check if accent is bottom (#6393) Co-authored-by: Laurenz --- crates/typst-library/src/math/accent.rs | 30 +++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/crates/typst-library/src/math/accent.rs b/crates/typst-library/src/math/accent.rs index f2c9168c2..c8569ea23 100644 --- a/crates/typst-library/src/math/accent.rs +++ b/crates/typst-library/src/math/accent.rs @@ -1,3 +1,10 @@ +use std::sync::LazyLock; + +use icu_properties::maps::CodePointMapData; +use icu_properties::CanonicalCombiningClass; +use icu_provider::AsDeserializingBufferProvider; +use icu_provider_blob::BlobDataProvider; + use crate::diag::bail; use crate::foundations::{cast, elem, func, Content, NativeElement, SymbolElem}; use crate::layout::{Length, Rel}; @@ -81,17 +88,22 @@ impl Accent { Self(Self::combine(c).unwrap_or(c)) } - /// List of bottom accents. Currently just a list of ones included in the - /// Unicode math class document. - const BOTTOM: &[char] = &[ - '\u{0323}', '\u{032C}', '\u{032D}', '\u{032E}', '\u{032F}', '\u{0330}', - '\u{0331}', '\u{0332}', '\u{0333}', '\u{033A}', '\u{20E8}', '\u{20EC}', - '\u{20ED}', '\u{20EE}', '\u{20EF}', - ]; - /// Whether this accent is a bottom accent or not. pub fn is_bottom(&self) -> bool { - Self::BOTTOM.contains(&self.0) + static COMBINING_CLASS_DATA: LazyLock> = + LazyLock::new(|| { + icu_properties::maps::load_canonical_combining_class( + &BlobDataProvider::try_new_from_static_blob(typst_assets::icu::ICU) + .unwrap() + .as_deserializing(), + ) + .unwrap() + }); + + matches!( + COMBINING_CLASS_DATA.as_borrowed().get(self.0), + CanonicalCombiningClass::Below + ) } } From 87c56865606e027f552a4dbc800c6851b0d0b821 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 24 Jun 2025 13:22:55 +0300 Subject: [PATCH 026/101] Add docs for `std` module (#6407) Co-authored-by: Laurenz --- crates/typst-library/src/lib.rs | 2 +- docs/reference/groups.yml | 53 +++++++++++++++++++++++++++++++++ docs/src/lib.rs | 2 +- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/crates/typst-library/src/lib.rs b/crates/typst-library/src/lib.rs index c39024f71..fa7977888 100644 --- a/crates/typst-library/src/lib.rs +++ b/crates/typst-library/src/lib.rs @@ -148,7 +148,7 @@ pub struct Library { /// The default style properties (for page size, font selection, and /// everything else configurable via set and show rules). pub styles: Styles, - /// The standard library as a value. Used to provide the `std` variable. + /// The standard library as a value. Used to provide the `std` module. pub std: Binding, /// In-development features that were enabled. pub features: Features, diff --git a/docs/reference/groups.yml b/docs/reference/groups.yml index e5aa7e999..c7e3d9964 100644 --- a/docs/reference/groups.yml +++ b/docs/reference/groups.yml @@ -137,6 +137,59 @@ In addition to the functions listed below, the `calc` module also defines the constants `pi`, `tau`, `e`, and `inf`. +- name: std + title: Standard library + category: foundations + path: ["std"] + details: | + A module that contains all globally accessible items. + + # Using "shadowed" definitions + The `std` module is useful whenever you overrode a name from the global + scope (this is called _shadowing_). For instance, you might have used the + name `text` for a parameter. To still access the `text` element, write + `std.text`. + + ```example + >>> #set page(margin: (left: 3em)) + #let par = [My special paragraph.] + #let special(text) = { + set std.text(style: "italic") + set std.par.line(numbering: "1") + text + } + + #special(par) + + #lorem(10) + ``` + + # Conditional access + You can also use this in combination with the [dictionary + constructor]($dictionary) to conditionally access global definitions. This + can, for instance, be useful to use new or experimental functionality when + it is available, while falling back to an alternative implementation if + used on an older Typst version. In particular, this allows us to create + [polyfills](https://en.wikipedia.org/wiki/Polyfill_(programming)). + + This can be as simple as creating an alias to prevent warning messages, for + example, conditionally using `pattern` in Typst version 0.12, but using + [`tiling`] in newer versions. Since the parameters accepted by the `tiling` + function match those of the older `pattern` function, using the `tiling` + function when available and falling back to `pattern` otherwise will unify + the usage across all versions. Note that, when creating a polyfill, + [`sys.version`]($category/foundations/sys) can also be very useful. + + ```typ + #let tiling = if "tiling" in dictionary(std) { + tiling + } else { + pattern + } + + ... + ``` + - name: sys title: System category: foundations diff --git a/docs/src/lib.rs b/docs/src/lib.rs index b81f0dc66..9bd21c2e8 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -37,7 +37,7 @@ static GROUPS: LazyLock> = LazyLock::new(|| { let mut groups: Vec = yaml::from_str(load!("reference/groups.yml")).unwrap(); for group in &mut groups { - if group.filter.is_empty() { + if group.filter.is_empty() && group.name != "std" { group.filter = group .module() .scope() From f162c371017f0d503cfae8738cbbf505b9f11173 Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 24 Jun 2025 15:49:28 +0300 Subject: [PATCH 027/101] Improve equation reference example (#6481) --- crates/typst-library/src/model/reference.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index f22d70b32..6fddc56ca 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -91,16 +91,13 @@ use crate::text::TextElem; /// #show ref: it => { /// let eq = math.equation /// let el = it.element -/// if el != none and el.func() == eq { -/// // Override equation references. -/// link(el.location(),numbering( -/// el.numbering, -/// ..counter(eq).at(el.location()) -/// )) -/// } else { -/// // Other references as usual. -/// it -/// } +/// // Skip all other references. +/// if el == none or el.func() != eq { return it } +/// // Override equation references. +/// link(el.location(), numbering( +/// el.numbering, +/// ..counter(eq).at(el.location()) +/// )) /// } /// /// = Beginnings From d4be7c4ca54ce1907ce5f7af8a603cf3f4c5a42f Mon Sep 17 00:00:00 2001 From: Andrew Voynov <37143421+Andrew15-5@users.noreply.github.com> Date: Tue, 24 Jun 2025 16:00:51 +0300 Subject: [PATCH 028/101] Add page reference customization example (#6480) Co-authored-by: Laurenz --- crates/typst-library/src/model/reference.rs | 30 +++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index 6fddc56ca..17f93b7c4 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -79,6 +79,36 @@ use crate::text::TextElem; /// reference: `[@intro[Chapter]]`. /// /// # Customization +/// When you only ever need to reference pages of a figure/table/heading/etc. in +/// a document, the default `form` field value can be changed to `{"page"}` with +/// a set rule. If you prefer a short "p." supplement over "page", the +/// [`page.supplement`]($page.supplement) field can be used for changing this: +/// +/// ```example +/// #set page( +/// numbering: "1", +/// supplement: "p.", +/// >>> margin: (bottom: 3em), +/// >>> footer-descent: 1.25em, +/// ) +/// #set ref(form: "page") +/// +/// #figure( +/// stack( +/// dir: ltr, +/// spacing: 1em, +/// circle(), +/// square(), +/// ), +/// caption: [Shapes], +/// ) +/// +/// #pagebreak() +/// +/// See @shapes for examples +/// of different shapes. +/// ``` +/// /// If you write a show rule for references, you can access the referenced /// element through the `element` field of the reference. The `element` may /// be `{none}` even if it exists if Typst hasn't discovered it yet, so you From 70399a94fd58cc5e3e953c10670c396de8f7f6f7 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Tue, 24 Jun 2025 15:23:37 +0200 Subject: [PATCH 029/101] Bump `krilla` to current Git version (#6488) Co-authored-by: Laurenz --- Cargo.lock | 18 ++++++++---------- Cargo.toml | 4 ++-- crates/typst-pdf/src/embed.rs | 3 +-- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58cac3c58..3ea423f5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -786,9 +786,9 @@ checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f" [[package]] name = "font-types" -version = "0.8.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa6a5e5a77b5f3f7f9e32879f484aa5b3632ddfbe568a16266c904a6f32cdaf" +checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7" dependencies = [ "bytemuck", ] @@ -1367,8 +1367,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ee6128ebf52d7ce684613b6431ead2959f2be9ff8cf776eeaaad0427c953e9" +source = "git+https://github.com/LaurenzV/krilla?rev=20c14fe#20c14fefee5002566b3d6668b338bbe2168784e7" dependencies = [ "base64", "bumpalo", @@ -1396,8 +1395,7 @@ dependencies = [ [[package]] name = "krilla-svg" version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3462989578155cf620ef8035f8921533cc95c28e2a0c75de172f7219e6aba84e" +source = "git+https://github.com/LaurenzV/krilla?rev=20c14fe#20c14fefee5002566b3d6668b338bbe2168784e7" dependencies = [ "flate2", "fontdb", @@ -2106,9 +2104,9 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.28.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "600e807b48ac55bad68a8cb75cc3c7739f139b9248f7e003e01e080f589b5288" +checksum = "192735ef611aac958468e670cb98432c925426f3cb71521fda202130f7388d91" dependencies = [ "bytemuck", "font-types", @@ -2434,9 +2432,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "skrifa" -version = "0.30.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa1e5622e4f7b98877e8a19890efddcac1230cec6198bd9de91ec0e00010dc8" +checksum = "e6d632b5a73f566303dbeabd344dc3e716fd4ddc9a70d6fc8ea8e6f06617da97" dependencies = [ "bytemuck", "read-fonts", diff --git a/Cargo.toml b/Cargo.toml index 72ab9094d..3cfb72008 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,8 +73,8 @@ image = { version = "0.25.5", default-features = false, features = ["png", "jpeg indexmap = { version = "2", features = ["serde"] } infer = { version = "0.19.0", default-features = false } kamadak-exif = "0.6" -krilla = { version = "0.4.0", default-features = false, features = ["raster-images", "comemo", "rayon"] } -krilla-svg = "0.1.0" +krilla = { git = "https://github.com/LaurenzV/krilla", rev = "20c14fe", default-features = false, features = ["raster-images", "comemo", "rayon"] } +krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "20c14fe" } kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" diff --git a/crates/typst-pdf/src/embed.rs b/crates/typst-pdf/src/embed.rs index f0cd9060a..36330c445 100644 --- a/crates/typst-pdf/src/embed.rs +++ b/crates/typst-pdf/src/embed.rs @@ -34,8 +34,7 @@ pub(crate) fn embed_files( }, }; let data: Arc + Send + Sync> = Arc::new(embed.data.clone()); - // TODO: update when new krilla version lands (https://github.com/LaurenzV/krilla/pull/203) - let compress = should_compress(&embed.data).unwrap_or(true); + let compress = should_compress(&embed.data); let file = EmbeddedFile { path, From 9e3c1199edddc0422d34a266681d2efe1babd0c1 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 24 Jun 2025 17:05:02 +0200 Subject: [PATCH 030/101] Check that git tree is clean after build (#6495) --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5c81537b..2354de582 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,7 @@ jobs: - run: cargo clippy --workspace --all-targets --no-default-features - run: cargo fmt --check --all - run: cargo doc --workspace --no-deps + - run: git diff --exit-code min-version: name: Check minimum Rust version From f2f527c451b1b05b393af99b89c528aadb203ce6 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Tue, 24 Jun 2025 17:52:15 +0200 Subject: [PATCH 031/101] Also fix encoding of ` + diff --git a/tests/suite/html/syntax.typ b/tests/suite/html/syntax.typ index eb1c86994..4bda0c686 100644 --- a/tests/suite/html/syntax.typ +++ b/tests/suite/html/syntax.typ @@ -11,6 +11,9 @@ #html.pre("\nhello") #html.pre("\n\nhello") +--- html-textarea-starting-with-newline html --- +#html.textarea("\nenter") + --- html-script html --- // This should be pretty and indented. #html.script( From d54544297beba0a762bee9bc731baab96e4d7250 Mon Sep 17 00:00:00 2001 From: +merlan #flirora Date: Wed, 25 Jun 2025 12:58:40 -0400 Subject: [PATCH 032/101] Minor fixes to doc comments (#6500) --- crates/typst-layout/src/inline/line.rs | 6 +++++- crates/typst-library/src/model/bibliography.rs | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/typst-layout/src/inline/line.rs b/crates/typst-layout/src/inline/line.rs index 659d33f4a..f05189275 100644 --- a/crates/typst-layout/src/inline/line.rs +++ b/crates/typst-layout/src/inline/line.rs @@ -640,7 +640,7 @@ impl<'a> Items<'a> { self.0.push(entry.into()); } - /// Iterate over the items + /// Iterate over the items. pub fn iter(&self) -> impl Iterator> { self.0.iter().map(|item| &**item) } @@ -698,6 +698,10 @@ impl Debug for Items<'_> { } /// A reference to or a boxed item. +/// +/// This is conceptually similar to a [`Cow<'a, Item<'a>>`][std::borrow::Cow], +/// but we box owned items since an [`Item`] is much bigger than +/// a box. pub enum ItemEntry<'a> { Ref(&'a Item<'a>), Box(Box>), diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 8056d4ab3..e1a073594 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -592,7 +592,7 @@ impl Works { /// Context for generating the bibliography. struct Generator<'a> { - /// The routines that is used to evaluate mathematical material in citations. + /// The routines that are used to evaluate mathematical material in citations. routines: &'a Routines, /// The world that is used to evaluate mathematical material in citations. world: Tracked<'a, dyn World + 'a>, @@ -609,7 +609,7 @@ struct Generator<'a> { /// Details about a group of merged citations. All citations are put into groups /// of adjacent ones (e.g., `@foo @bar` will merge into a group of length two). -/// Even single citations will be put into groups of length ones. +/// Even single citations will be put into groups of length one. struct GroupInfo { /// The group's location. location: Location, From d3caedd813b1ca4379a71eb1b4aa636096d53a04 Mon Sep 17 00:00:00 2001 From: Connor K Date: Wed, 25 Jun 2025 12:59:19 -0400 Subject: [PATCH 033/101] Fix typos in page-setup.md (#6499) --- docs/guides/page-setup.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/guides/page-setup.md b/docs/guides/page-setup.md index 36ed0fa23..1682c1220 100644 --- a/docs/guides/page-setup.md +++ b/docs/guides/page-setup.md @@ -206,7 +206,6 @@ label exists on the current page: ```typ >>> #set page("a5", margin: (x: 2.5cm, y: 3cm)) #set page(header: context { - let page-counter = let matches = query() let current = counter(page).get() let has-table = matches.any(m => @@ -218,7 +217,7 @@ label exists on the current page: #h(1fr) National Academy of Sciences ] -})) +}) #lorem(100) #pagebreak() From 35809387f88483bfa3d0978cfc3303eba0de632b Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 26 Jun 2025 10:06:22 +0200 Subject: [PATCH 034/101] Support `in` operator on strings and modules (#6498) --- .../typst-library/src/foundations/module.rs | 19 +++++++++++++++---- crates/typst-library/src/foundations/ops.rs | 1 + docs/reference/groups.yml | 6 +----- tests/suite/scripting/ops.typ | 2 ++ 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/typst-library/src/foundations/module.rs b/crates/typst-library/src/foundations/module.rs index 55d8bab63..14eefca39 100644 --- a/crates/typst-library/src/foundations/module.rs +++ b/crates/typst-library/src/foundations/module.rs @@ -19,11 +19,8 @@ use crate::foundations::{repr, ty, Content, Scope, Value}; /// /// You can access definitions from the module using [field access /// notation]($scripting/#fields) and interact with it using the [import and -/// include syntaxes]($scripting/#modules). Alternatively, it is possible to -/// convert a module to a dictionary, and therefore access its contents -/// dynamically, using the [dictionary constructor]($dictionary/#constructor). +/// include syntaxes]($scripting/#modules). /// -/// # Example /// ```example /// <<< #import "utils.typ" /// <<< #utils.add(2, 5) @@ -34,6 +31,20 @@ use crate::foundations::{repr, ty, Content, Scope, Value}; /// >>> /// >>> #(-3) /// ``` +/// +/// You can check whether a definition is present in a module using the `{in}` +/// operator, with a string on the left-hand side. This can be useful to +/// [conditionally access]($category/foundations/std/#conditional-access) +/// definitions in a module. +/// +/// ```example +/// #("table" in std) \ +/// #("nope" in std) +/// ``` +/// +/// Alternatively, it is possible to convert a module to a dictionary, and +/// therefore access its contents dynamically, using the [dictionary +/// constructor]($dictionary/#constructor). #[ty(cast)] #[derive(Clone, Hash)] #[allow(clippy::derived_hash_with_manual_eq)] diff --git a/crates/typst-library/src/foundations/ops.rs b/crates/typst-library/src/foundations/ops.rs index 6c2408446..3c6a5e6cf 100644 --- a/crates/typst-library/src/foundations/ops.rs +++ b/crates/typst-library/src/foundations/ops.rs @@ -558,6 +558,7 @@ pub fn contains(lhs: &Value, rhs: &Value) -> Option { (Str(a), Str(b)) => Some(b.as_str().contains(a.as_str())), (Dyn(a), Str(b)) => a.downcast::().map(|regex| regex.is_match(b)), (Str(a), Dict(b)) => Some(b.contains(a)), + (Str(a), Module(b)) => Some(b.scope().get(a).is_some()), (a, Array(b)) => Some(b.contains(a.clone())), _ => Option::None, diff --git a/docs/reference/groups.yml b/docs/reference/groups.yml index c7e3d9964..e01d99dc4 100644 --- a/docs/reference/groups.yml +++ b/docs/reference/groups.yml @@ -181,11 +181,7 @@ [`sys.version`]($category/foundations/sys) can also be very useful. ```typ - #let tiling = if "tiling" in dictionary(std) { - tiling - } else { - pattern - } + #let tiling = if "tiling" in std { tiling } else { pattern } ... ``` diff --git a/tests/suite/scripting/ops.typ b/tests/suite/scripting/ops.typ index d17c0117f..561682f05 100644 --- a/tests/suite/scripting/ops.typ +++ b/tests/suite/scripting/ops.typ @@ -264,6 +264,8 @@ #test("Hey" not in "abheyCd", true) #test("a" not /* fun comment? */ in "abc", false) +#test("sys" in std, true) +#test("system" in std, false) --- ops-not-trailing --- // Error: 10 expected keyword `in` From 6a1d6c08e2d6e4c184c6d177e67796b23ccbe4c7 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Thu, 26 Jun 2025 10:07:41 +0200 Subject: [PATCH 035/101] Consistent sizing for `html.frame` (#6505) --- crates/typst-html/src/encode.rs | 15 ++++++++++----- crates/typst-html/src/lib.rs | 7 +++++-- crates/typst-library/src/html/dom.rs | 17 ++++++++++++++--- .../src/introspection/introspector.rs | 2 +- 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/crates/typst-html/src/encode.rs b/crates/typst-html/src/encode.rs index 9c7938360..84860dbe9 100644 --- a/crates/typst-html/src/encode.rs +++ b/crates/typst-html/src/encode.rs @@ -3,9 +3,8 @@ use std::fmt::Write; use typst_library::diag::{bail, At, SourceResult, StrResult}; use typst_library::foundations::Repr; use typst_library::html::{ - attr, charsets, tag, HtmlDocument, HtmlElement, HtmlNode, HtmlTag, + attr, charsets, tag, HtmlDocument, HtmlElement, HtmlFrame, HtmlNode, HtmlTag, }; -use typst_library::layout::Frame; use typst_syntax::Span; /// Encodes an HTML document into a string. @@ -304,9 +303,15 @@ fn write_escape(w: &mut Writer, c: char) -> StrResult<()> { } /// Encode a laid out frame into the writer. -fn write_frame(w: &mut Writer, frame: &Frame) { +fn write_frame(w: &mut Writer, frame: &HtmlFrame) { // FIXME: This string replacement is obviously a hack. - let svg = typst_svg::svg_frame(frame) - .replace(" Self::intern(&v)?, } +/// Layouted content that will be embedded into HTML as an SVG. +#[derive(Debug, Clone, Hash)] +pub struct HtmlFrame { + /// The frame that will be displayed as an SVG. + pub inner: Frame, + /// The text size where the frame was defined. This is used to size the + /// frame with em units to make text in and outside of the frame sized + /// consistently. + pub text_size: Abs, +} + /// Defines syntactical properties of HTML tags, attributes, and text. pub mod charsets { /// Check whether a character is in a tag name. diff --git a/crates/typst-library/src/introspection/introspector.rs b/crates/typst-library/src/introspection/introspector.rs index 9751dfcb8..d2ad0525b 100644 --- a/crates/typst-library/src/introspection/introspector.rs +++ b/crates/typst-library/src/introspection/introspector.rs @@ -446,7 +446,7 @@ impl IntrospectorBuilder { HtmlNode::Element(elem) => self.discover_in_html(sink, &elem.children), HtmlNode::Frame(frame) => self.discover_in_frame( sink, - frame, + &frame.inner, NonZeroUsize::ONE, Transform::identity(), ), From 04fd0acacab8cf2e82268da9c18ef4bcf37507dc Mon Sep 17 00:00:00 2001 From: Malo <57839069+MDLC01@users.noreply.github.com> Date: Thu, 26 Jun 2025 09:24:21 +0100 Subject: [PATCH 036/101] Allow deprecating symbol variants (#6441) --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/typst-ide/src/complete.rs | 2 +- .../typst-library/src/foundations/symbol.rs | 59 ++++++++++++------- crates/typst-library/src/foundations/value.rs | 4 +- docs/src/lib.rs | 14 ++--- tests/suite/math/attach.typ | 6 +- 7 files changed, 51 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ea423f5f..91ff48432 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -413,7 +413,7 @@ dependencies = [ [[package]] name = "codex" version = "0.1.1" -source = "git+https://github.com/typst/codex?rev=56eb217#56eb2172fc0670f4c1c8b79a63d11f9354e5babe" +source = "git+https://github.com/typst/codex?rev=a5428cb#a5428cb9c81a41354d44b44dbd5a16a710bbd928" [[package]] name = "color-print" diff --git a/Cargo.toml b/Cargo.toml index 3cfb72008..76d83995f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,7 +47,7 @@ clap = { version = "4.4", features = ["derive", "env", "wrap_help"] } clap_complete = "4.2.1" clap_mangen = "0.2.10" codespan-reporting = "0.11" -codex = { git = "https://github.com/typst/codex", rev = "56eb217" } +codex = { git = "https://github.com/typst/codex", rev = "a5428cb" } color-print = "0.3.6" comemo = "0.4" csv = "1" diff --git a/crates/typst-ide/src/complete.rs b/crates/typst-ide/src/complete.rs index 536423318..bc5b3e10e 100644 --- a/crates/typst-ide/src/complete.rs +++ b/crates/typst-ide/src/complete.rs @@ -448,7 +448,7 @@ fn field_access_completions( match value { Value::Symbol(symbol) => { for modifier in symbol.modifiers() { - if let Ok(modified) = symbol.clone().modified(modifier) { + if let Ok(modified) = symbol.clone().modified((), modifier) { ctx.completions.push(Completion { kind: CompletionKind::Symbol(modified.get()), label: modifier.into(), diff --git a/crates/typst-library/src/foundations/symbol.rs b/crates/typst-library/src/foundations/symbol.rs index 0f503edd0..f57bb0c2a 100644 --- a/crates/typst-library/src/foundations/symbol.rs +++ b/crates/typst-library/src/foundations/symbol.rs @@ -8,7 +8,7 @@ use serde::{Serialize, Serializer}; use typst_syntax::{is_ident, Span, Spanned}; use typst_utils::hash128; -use crate::diag::{bail, SourceResult, StrResult}; +use crate::diag::{bail, DeprecationSink, SourceResult, StrResult}; use crate::foundations::{ cast, elem, func, scope, ty, Array, Content, Func, NativeElement, NativeFunc, Packed, PlainText, Repr as _, @@ -54,18 +54,22 @@ enum Repr { /// A native symbol that has no named variant. Single(char), /// A native symbol with multiple named variants. - Complex(&'static [(ModifierSet<&'static str>, char)]), + Complex(&'static [Variant<&'static str>]), /// A symbol with multiple named variants, where some modifiers may have /// been applied. Also used for symbols defined at runtime by the user with /// no modifier applied. Modified(Arc<(List, ModifierSet)>), } +/// A symbol variant, consisting of a set of modifiers, a character, and an +/// optional deprecation message. +type Variant = (ModifierSet, char, Option); + /// A collection of symbols. #[derive(Clone, Eq, PartialEq, Hash)] enum List { - Static(&'static [(ModifierSet<&'static str>, char)]), - Runtime(Box<[(ModifierSet, char)]>), + Static(&'static [Variant<&'static str>]), + Runtime(Box<[Variant]>), } impl Symbol { @@ -76,14 +80,14 @@ impl Symbol { /// Create a symbol with a static variant list. #[track_caller] - pub const fn list(list: &'static [(ModifierSet<&'static str>, char)]) -> Self { + pub const fn list(list: &'static [Variant<&'static str>]) -> Self { debug_assert!(!list.is_empty()); Self(Repr::Complex(list)) } /// Create a symbol with a runtime variant list. #[track_caller] - pub fn runtime(list: Box<[(ModifierSet, char)]>) -> Self { + pub fn runtime(list: Box<[Variant]>) -> Self { debug_assert!(!list.is_empty()); Self(Repr::Modified(Arc::new((List::Runtime(list), ModifierSet::default())))) } @@ -93,9 +97,11 @@ impl Symbol { match &self.0 { Repr::Single(c) => *c, Repr::Complex(_) => ModifierSet::<&'static str>::default() - .best_match_in(self.variants()) + .best_match_in(self.variants().map(|(m, c, _)| (m, c))) .unwrap(), - Repr::Modified(arc) => arc.1.best_match_in(self.variants()).unwrap(), + Repr::Modified(arc) => { + arc.1.best_match_in(self.variants().map(|(m, c, _)| (m, c))).unwrap() + } } } @@ -128,7 +134,11 @@ impl Symbol { } /// Apply a modifier to the symbol. - pub fn modified(mut self, modifier: &str) -> StrResult { + pub fn modified( + mut self, + sink: impl DeprecationSink, + modifier: &str, + ) -> StrResult { if let Repr::Complex(list) = self.0 { self.0 = Repr::Modified(Arc::new((List::Static(list), ModifierSet::default()))); @@ -137,7 +147,12 @@ impl Symbol { if let Repr::Modified(arc) = &mut self.0 { let (list, modifiers) = Arc::make_mut(arc); modifiers.insert_raw(modifier); - if modifiers.best_match_in(list.variants()).is_some() { + if let Some(deprecation) = + modifiers.best_match_in(list.variants().map(|(m, _, d)| (m, d))) + { + if let Some(message) = deprecation { + sink.emit(message) + } return Ok(self); } } @@ -146,7 +161,7 @@ impl Symbol { } /// The characters that are covered by this symbol. - pub fn variants(&self) -> impl Iterator, char)> { + pub fn variants(&self) -> impl Iterator> { match &self.0 { Repr::Single(c) => Variants::Single(Some(*c).into_iter()), Repr::Complex(list) => Variants::Static(list.iter()), @@ -161,7 +176,7 @@ impl Symbol { _ => ModifierSet::default(), }; self.variants() - .flat_map(|(m, _)| m) + .flat_map(|(m, _, _)| m) .filter(|modifier| !modifier.is_empty() && !modifiers.contains(modifier)) .collect::>() .into_iter() @@ -256,7 +271,7 @@ impl Symbol { let list = variants .into_iter() - .map(|s| (ModifierSet::from_raw_dotted(s.v.0), s.v.1)) + .map(|s| (ModifierSet::from_raw_dotted(s.v.0), s.v.1, None)) .collect(); Ok(Symbol::runtime(list)) } @@ -316,17 +331,17 @@ impl crate::foundations::Repr for Symbol { } fn repr_variants<'a>( - variants: impl Iterator, char)>, + variants: impl Iterator>, applied_modifiers: ModifierSet<&str>, ) -> String { crate::foundations::repr::pretty_array_like( &variants - .filter(|(modifiers, _)| { + .filter(|(modifiers, _, _)| { // Only keep variants that can still be accessed, i.e., variants // that contain all applied modifiers. applied_modifiers.iter().all(|am| modifiers.contains(am)) }) - .map(|(modifiers, c)| { + .map(|(modifiers, c, _)| { let trimmed_modifiers = modifiers.into_iter().filter(|&m| !applied_modifiers.contains(m)); if trimmed_modifiers.clone().all(|m| m.is_empty()) { @@ -379,18 +394,20 @@ cast! { /// Iterator over variants. enum Variants<'a> { Single(std::option::IntoIter), - Static(std::slice::Iter<'static, (ModifierSet<&'static str>, char)>), - Runtime(std::slice::Iter<'a, (ModifierSet, char)>), + Static(std::slice::Iter<'static, Variant<&'static str>>), + Runtime(std::slice::Iter<'a, Variant>), } impl<'a> Iterator for Variants<'a> { - type Item = (ModifierSet<&'a str>, char); + type Item = Variant<&'a str>; fn next(&mut self) -> Option { match self { - Self::Single(iter) => Some((ModifierSet::default(), iter.next()?)), + Self::Single(iter) => Some((ModifierSet::default(), iter.next()?, None)), Self::Static(list) => list.next().copied(), - Self::Runtime(list) => list.next().map(|(m, c)| (m.as_deref(), *c)), + Self::Runtime(list) => { + list.next().map(|(m, c, d)| (m.as_deref(), *c, d.as_deref())) + } } } } diff --git a/crates/typst-library/src/foundations/value.rs b/crates/typst-library/src/foundations/value.rs index 854c2486e..4bcf2d4e3 100644 --- a/crates/typst-library/src/foundations/value.rs +++ b/crates/typst-library/src/foundations/value.rs @@ -157,7 +157,9 @@ impl Value { /// Try to access a field on the value. pub fn field(&self, field: &str, sink: impl DeprecationSink) -> StrResult { match self { - Self::Symbol(symbol) => symbol.clone().modified(field).map(Self::Symbol), + Self::Symbol(symbol) => { + symbol.clone().modified(sink, field).map(Self::Symbol) + } Self::Version(version) => version.component(field).map(Self::Int), Self::Dict(dict) => dict.get(field).cloned(), Self::Content(content) => content.field_by_name(field), diff --git a/docs/src/lib.rs b/docs/src/lib.rs index 9bd21c2e8..dc6b62c72 100644 --- a/docs/src/lib.rs +++ b/docs/src/lib.rs @@ -720,18 +720,12 @@ fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { } }; - for (variant, c) in symbol.variants() { + for (variant, c, deprecation) in symbol.variants() { let shorthand = |list: &[(&'static str, char)]| { list.iter().copied().find(|&(_, x)| x == c).map(|(s, _)| s) }; let name = complete(variant); - let deprecation = match name.as_str() { - "integral.sect" => { - Some("`integral.sect` is deprecated, use `integral.inter` instead") - } - _ => binding.deprecation(), - }; list.push(SymbolModel { name, @@ -742,10 +736,10 @@ fn symbols_model(resolver: &dyn Resolver, group: &GroupData) -> SymbolsModel { accent: typst::math::Accent::combine(c).is_some(), alternates: symbol .variants() - .filter(|(other, _)| other != &variant) - .map(|(other, _)| complete(other)) + .filter(|(other, _, _)| other != &variant) + .map(|(other, _, _)| complete(other)) .collect(), - deprecation, + deprecation: deprecation.or_else(|| binding.deprecation()), }); } } diff --git a/tests/suite/math/attach.typ b/tests/suite/math/attach.typ index cedc3a4ab..979018478 100644 --- a/tests/suite/math/attach.typ +++ b/tests/suite/math/attach.typ @@ -121,8 +121,8 @@ $a scripts(=)^"def" b quad a scripts(lt.eq)_"really" b quad a scripts(arrow.r.lo --- math-attach-integral --- // Test default of scripts attachments on integrals at display size. -$ integral.sect_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b $ -$integral.sect_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b$ +$ integral.inter_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b $ +$integral.inter_a^b quad \u{2a1b}_a^b quad limits(\u{2a1b})_a^b$ --- math-attach-large-operator --- // Test default of limit attachments on large operators at display size only. @@ -179,7 +179,7 @@ $ a0 + a1 + a0_2 \ #{ let var = $x^1$ for i in range(24) { - var = $var$ + var = $var$ } $var_2$ } From 5dd5771df03a666fe17930b0b071b06266e5937f Mon Sep 17 00:00:00 2001 From: "Said A." <47973576+Daaiid@users.noreply.github.com> Date: Thu, 26 Jun 2025 11:18:51 +0200 Subject: [PATCH 037/101] Disallow empty labels and references (#5776) (#6332) Co-authored-by: Laurenz --- crates/typst-eval/src/markup.rs | 7 ++++-- crates/typst-ide/src/definition.rs | 3 ++- crates/typst-library/src/foundations/label.rs | 23 ++++++++++++------ .../typst-library/src/model/bibliography.rs | 6 ++++- crates/typst-syntax/src/ast.rs | 2 ++ crates/typst-syntax/src/lexer.rs | 2 +- tests/ref/ref-to-empty-label-not-possible.png | Bin 0 -> 182 bytes tests/suite/foundations/label.typ | 4 +++ tests/suite/model/bibliography.typ | 8 ++++++ tests/suite/model/ref.typ | 11 +++++++++ 10 files changed, 54 insertions(+), 12 deletions(-) create mode 100644 tests/ref/ref-to-empty-label-not-possible.png diff --git a/crates/typst-eval/src/markup.rs b/crates/typst-eval/src/markup.rs index 5beefa912..9118ded56 100644 --- a/crates/typst-eval/src/markup.rs +++ b/crates/typst-eval/src/markup.rs @@ -205,7 +205,9 @@ impl Eval for ast::Label<'_> { type Output = Value; fn eval(self, _: &mut Vm) -> SourceResult { - Ok(Value::Label(Label::new(PicoStr::intern(self.get())))) + Ok(Value::Label( + Label::new(PicoStr::intern(self.get())).expect("unexpected empty label"), + )) } } @@ -213,7 +215,8 @@ impl Eval for ast::Ref<'_> { type Output = Content; fn eval(self, vm: &mut Vm) -> SourceResult { - let target = Label::new(PicoStr::intern(self.target())); + let target = Label::new(PicoStr::intern(self.target())) + .expect("unexpected empty reference"); let mut elem = RefElem::new(target); if let Some(supplement) = self.supplement() { elem.push_supplement(Smart::Custom(Some(Supplement::Content( diff --git a/crates/typst-ide/src/definition.rs b/crates/typst-ide/src/definition.rs index 69d702b3b..ae1ba287b 100644 --- a/crates/typst-ide/src/definition.rs +++ b/crates/typst-ide/src/definition.rs @@ -72,7 +72,8 @@ pub fn definition( // Try to jump to the referenced content. DerefTarget::Ref(node) => { - let label = Label::new(PicoStr::intern(node.cast::()?.target())); + let label = Label::new(PicoStr::intern(node.cast::()?.target())) + .expect("unexpected empty reference"); let selector = Selector::Label(label); let elem = document?.introspector.query_first(&selector)?; return Some(Definition::Span(elem.span())); diff --git a/crates/typst-library/src/foundations/label.rs b/crates/typst-library/src/foundations/label.rs index 3b9b010c5..b1ac58bf2 100644 --- a/crates/typst-library/src/foundations/label.rs +++ b/crates/typst-library/src/foundations/label.rs @@ -1,7 +1,8 @@ use ecow::{eco_format, EcoString}; use typst_utils::{PicoStr, ResolvedPicoStr}; -use crate::foundations::{func, scope, ty, Repr, Str}; +use crate::diag::StrResult; +use crate::foundations::{bail, func, scope, ty, Repr, Str}; /// A label for an element. /// @@ -27,7 +28,8 @@ use crate::foundations::{func, scope, ty, Repr, Str}; /// # Syntax /// This function also has dedicated syntax: You can create a label by enclosing /// its name in angle brackets. This works both in markup and code. A label's -/// name can contain letters, numbers, `_`, `-`, `:`, and `.`. +/// name can contain letters, numbers, `_`, `-`, `:`, and `.`. A label cannot +/// be empty. /// /// Note that there is a syntactical difference when using the dedicated syntax /// for this function. In the code below, the `[]` terminates the heading and @@ -50,8 +52,11 @@ pub struct Label(PicoStr); impl Label { /// Creates a label from an interned string. - pub fn new(name: PicoStr) -> Self { - Self(name) + /// + /// Returns `None` if the given string is empty. + pub fn new(name: PicoStr) -> Option { + const EMPTY: PicoStr = PicoStr::constant(""); + (name != EMPTY).then_some(Self(name)) } /// Resolves the label to a string. @@ -70,10 +75,14 @@ impl Label { /// Creates a label from a string. #[func(constructor)] pub fn construct( - /// The name of the label. + /// The name of the label. Must not be empty. name: Str, - ) -> Label { - Self(PicoStr::intern(name.as_str())) + ) -> StrResult

${title}