Compare commits

...

8 Commits

Author SHA1 Message Date
Tobias Schmitz
2242b7f598
Merge 9649def1081720d351def471cceb735fb594ea9e into b790c6d59ceaf7a809cc24b60c1f1509807470e2 2025-07-18 16:47:12 +02:00
Tobias Schmitz
9649def108
feat: add alt parameter to math.equation 2025-07-18 16:38:54 +02:00
Tobias Schmitz
d2105dcc35
feat: report spans for missing alt text and unknown/duplicate tag ids 2025-07-18 16:38:54 +02:00
Erik
b790c6d59c
Add rust-analyzer to flake devShell (#6618) 2025-07-18 14:36:10 +00:00
Malo
b1c79b50d4
Fix documentation oneliners (#6608) 2025-07-18 13:25:17 +00:00
Patrick Massot
4629ede020
Mention Tinymist in README.md (#6601) 2025-07-18 13:21:36 +00:00
Lachlan Kermode
627f5b9d4f
Add show rule for smallcaps in HTML (#6600) 2025-07-17 16:09:13 +00:00
Robin
5661c20580
Slightly improve selector docs (#6544) 2025-07-16 16:15:49 +00:00
18 changed files with 166 additions and 55 deletions

4
Cargo.lock generated
View File

@ -1373,7 +1373,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.4.0"
source = "git+https://github.com/LaurenzV/krilla?branch=main#d40f81a01ca8f8654510a76effeef12518437800"
source = "git+https://github.com/LaurenzV/krilla?branch=main#32d070e737cd8ae4c3aa4ff901d15cb22bd052f3"
dependencies = [
"base64",
"bumpalo",
@ -1402,7 +1402,7 @@ dependencies = [
[[package]]
name = "krilla-svg"
version = "0.1.0"
source = "git+https://github.com/LaurenzV/krilla?branch=main#d40f81a01ca8f8654510a76effeef12518437800"
source = "git+https://github.com/LaurenzV/krilla?branch=main#32d070e737cd8ae4c3aa4ff901d15cb22bd052f3"
dependencies = [
"flate2",
"fontdb",

View File

@ -173,8 +173,11 @@ typst help
typst help watch
```
If you prefer an integrated IDE-like experience with autocompletion and instant
preview, you can also check out [Typst's free web app][app].
If you prefer an integrated IDE-like experience with autocompletion and instant
preview, you can also check out our [free web app][app]. Alternatively, there is
a community-created language server called
[Tinymist](https://myriad-dreamin.github.io/tinymist/) which is integrated into
various editor extensions.
## Community
The main places where the community gathers are our [Forum][forum] and our

View File

@ -14,8 +14,8 @@ use typst_library::model::{
RefElem, StrongElem, TableCell, TableElem, TermsElem,
};
use typst_library::text::{
HighlightElem, LinebreakElem, OverlineElem, RawElem, RawLine, SpaceElem, StrikeElem,
SubElem, SuperElem, UnderlineElem,
HighlightElem, LinebreakElem, OverlineElem, RawElem, RawLine, SmallcapsElem,
SpaceElem, StrikeElem, SubElem, SuperElem, UnderlineElem,
};
use typst_library::visualize::ImageElem;
@ -47,6 +47,7 @@ pub fn register(rules: &mut NativeRuleMap) {
rules.register(Html, OVERLINE_RULE);
rules.register(Html, STRIKE_RULE);
rules.register(Html, HIGHLIGHT_RULE);
rules.register(Html, SMALLCAPS_RULE);
rules.register(Html, RAW_RULE);
rules.register(Html, RAW_LINE_RULE);
@ -390,6 +391,20 @@ const STRIKE_RULE: ShowFn<StrikeElem> =
const HIGHLIGHT_RULE: ShowFn<HighlightElem> =
|elem, _, _| Ok(HtmlElem::new(tag::mark).with_body(Some(elem.body.clone())).pack());
const SMALLCAPS_RULE: ShowFn<SmallcapsElem> = |elem, _, styles| {
Ok(HtmlElem::new(tag::span)
.with_attr(
attr::style,
if elem.all.get(styles) {
"font-variant-caps: all-small-caps"
} else {
"font-variant-caps: small-caps"
},
)
.with_body(Some(elem.body.clone()))
.pack())
};
const RAW_RULE: ShowFn<RawElem> = |elem, _, styles| {
let lines = elem.lines.as_deref().unwrap_or_default();

View File

@ -23,10 +23,10 @@ use serde::{Serialize, Serializer};
use typst_syntax::Span;
use typst_utils::singleton;
use crate::diag::{SourceResult, StrResult};
use crate::diag::{bail, SourceResult, StrResult};
use crate::engine::Engine;
use crate::foundations::{
func, repr, scope, ty, Context, Dict, IntoValue, Label, Property, Recipe,
func, repr, scope, ty, Args, Context, Dict, IntoValue, Label, Property, Recipe,
RecipeIndex, Repr, Selector, Str, Style, StyleChain, Styles, Value,
};
use crate::introspection::{Locatable, Location};
@ -479,7 +479,7 @@ impl Content {
/// Link the content somewhere.
pub fn linked(self, dest: Destination, alt: Option<EcoString>) -> Self {
let span = self.span();
LinkMarker::new(self, dest.clone(), alt)
LinkMarker::new(self, dest.clone(), alt, span)
.pack()
.spanned(span)
.set(LinkElem::current, Some(dest))
@ -785,15 +785,27 @@ impl Repr for StyledElem {
}
/// An element that associates the body of a link with the destination.
#[elem(Locatable)]
#[elem(Locatable, Construct)]
pub struct LinkMarker {
/// The content.
#[internal]
#[required]
pub body: Content,
#[internal]
#[required]
pub dest: Destination,
#[internal]
#[required]
pub alt: Option<EcoString>,
#[internal]
#[required]
pub span: Span,
}
impl Construct for LinkMarker {
fn construct(_: &mut Engine, args: &mut Args) -> SourceResult<Content> {
bail!(args.span, "cannot be constructed manually");
}
}
impl<T: NativeElement> IntoValue for T {

View File

@ -37,13 +37,12 @@ pub use crate::__select_where as select_where;
/// A filter for selecting elements within the document.
///
/// You can construct a selector in the following ways:
/// - you can use an element [function]
/// - you can filter for an element function with
/// [specific fields]($function.where)
/// - you can use a [string]($str) or [regular expression]($regex)
/// - you can use a [`{<label>}`]($label)
/// - you can use a [`location`]
/// To construct a selector you can:
/// - use an element [function]
/// - filter for an element function with [specific fields]($function.where)
/// - use a [string]($str) or [regular expression]($regex)
/// - use a [`{<label>}`]($label)
/// - use a [`location`]
/// - call the [`selector`] constructor to convert any of the above types into a
/// selector value and use the methods below to refine it
///
@ -148,7 +147,9 @@ impl Selector {
impl Selector {
/// Turns a value into a selector. The following values are accepted:
/// - An element function like a `heading` or `figure`.
/// - A [string]($str) or [regular expression]($regex).
/// - A `{<label>}`.
/// - A [`location`].
/// - A more complex selector like `{heading.where(level: 1)}`.
#[func(constructor)]
pub fn construct(

View File

@ -1,6 +1,7 @@
use std::num::NonZeroUsize;
use codex::styling::MathVariant;
use ecow::EcoString;
use typst_utils::NonZeroExt;
use unicode_math_class::MathClass;
@ -47,6 +48,9 @@ use crate::text::{FontFamily, FontList, FontWeight, LocalName, TextElem};
/// [main math page]($category/math).
#[elem(Locatable, Synthesize, ShowSet, Count, LocalName, Refable, Outlinable)]
pub struct EquationElem {
/// An alternative description of the mathematical equation.
pub alt: Option<EcoString>,
/// Whether the equation is displayed as a separate block.
#[default(false)]
pub block: bool,

View File

@ -88,7 +88,7 @@ use crate::text::{LocalName, TextElem};
/// generated.
#[elem(Locatable)]
pub struct LinkElem {
/// A text describing the link.
/// An alternative description of the link.
pub alt: Option<EcoString>,
/// The destination the link points to.

View File

@ -797,7 +797,9 @@ impl Color {
components
}
/// Returns the constructor function for this color's space:
/// Returns the constructor function for this color's space.
///
/// Returns one of:
/// - [`luma`]($color.luma)
/// - [`oklab`]($color.oklab)
/// - [`oklch`]($color.oklch)

View File

@ -124,7 +124,7 @@ pub struct ImageElem {
/// The height of the image.
pub height: Sizing,
/// A text describing the image.
/// An alternative description of the image.
pub alt: Option<EcoString>,
/// How the image should adjust itself to a given area (the area is defined

View File

@ -9,6 +9,7 @@ use krilla::error::KrillaError;
use krilla::geom::PathBuilder;
use krilla::page::{PageLabel, PageSettings};
use krilla::surface::Surface;
use krilla::tagging::TagId;
use krilla::{Document, SerializeSettings};
use krilla_svg::render_svg_glyph;
use typst_library::diag::{bail, error, SourceDiagnostic, SourceResult};
@ -373,11 +374,21 @@ fn finish(
.collect::<EcoVec<_>>();
Err(errors)
}
KrillaError::DuplicateTagId(_, _) => {
unreachable!("duplicate IDs shouldn't be generated")
KrillaError::DuplicateTagId(id, loc) => {
let span = to_span(loc);
let id = display_tag_id(&id);
bail!(
span, "duplicate tag id `{id}`";
hint: "please report this as a bug"
)
}
KrillaError::UnknownTagId(_, _) => {
unreachable!("all referenced IDs should be present in the tag tree")
KrillaError::UnknownTagId(id, loc) => {
let span = to_span(loc);
let id = display_tag_id(&id);
bail!(
span, "unknown tag id `{id}`";
hint: "please report this as a bug"
)
}
KrillaError::Image(_, loc) => {
let span = to_span(loc);
@ -394,6 +405,20 @@ fn finish(
}
}
fn display_tag_id(id: &TagId) -> impl std::fmt::Display + use<'_> {
typst_utils::display(|f| {
if let Ok(str) = std::str::from_utf8(id.as_bytes()) {
f.write_str(str)
} else {
f.write_str("0x")?;
for b in id.as_bytes() {
write!(f, "{b:x}")?;
}
Ok(())
}
})
}
/// Converts a krilla error into a Typst error.
fn convert_error(
gc: &GlobalContext,
@ -562,16 +587,20 @@ fn convert_error(
}
// The below errors cannot occur yet, only once Typst supports full PDF/A
// and PDF/UA. But let's still add a message just to be on the safe side.
ValidationError::MissingAnnotationAltText => error!(
Span::detached(),
"{prefix} missing annotation alt text";
hint: "please report this as a bug"
),
ValidationError::MissingAltText => error!(
Span::detached(),
"{prefix} missing alt text";
hint: "make sure your images and equations have alt text"
),
ValidationError::MissingAnnotationAltText(loc) => {
let span = to_span(*loc);
error!(
span, "{prefix} missing annotation alt text";
hint: "please report this as a bug"
)
}
ValidationError::MissingAltText(loc) => {
let span = to_span(*loc);
error!(
span, "{prefix} missing alt text";
hint: "make sure your images and equations have alt text"
)
}
ValidationError::NoDocumentLanguage => error!(
Span::detached(),
"{prefix} missing document language";

View File

@ -6,6 +6,7 @@ use krilla::destination::XyzDestination;
use krilla::geom as kg;
use typst_library::layout::{Point, Position, Size};
use typst_library::model::Destination;
use typst_syntax::Span;
use crate::convert::{FrameContext, GlobalContext};
use crate::tags::{self, Placeholder, TagNode};
@ -17,6 +18,7 @@ pub(crate) struct LinkAnnotation {
pub(crate) alt: Option<String>,
pub(crate) quad_points: Vec<kg::Quadrilateral>,
pub(crate) target: Target,
pub(crate) span: Span,
}
pub(crate) fn handle_link(
@ -70,6 +72,7 @@ pub(crate) fn handle_link(
quad_points: vec![quad],
alt,
target,
span: link.span,
});
}
}

View File

@ -123,9 +123,9 @@ pub(crate) fn handle_start(
} else {
TagKind::Figure.with_alt_text(alt)
}
} else if let Some(_) = elem.to_packed::<EquationElem>() {
// TODO: alt text
TagKind::Formula.into()
} else if let Some(equation) = elem.to_packed::<EquationElem>() {
let alt = equation.alt.get_as_ref().map(|s| s.to_string());
TagKind::Formula.with_alt_text(alt)
} else if let Some(table) = elem.to_packed::<TableElem>() {
let table_id = gc.tags.next_table_id();
let summary = table.summary.get_as_ref().map(|s| s.to_string());
@ -176,6 +176,7 @@ pub(crate) fn handle_start(
return Ok(());
};
let tag = tag.with_location(Some(elem.span().into_raw().get()));
push_stack(gc, loc, StackEntryKind::Standard(tag))?;
Ok(())
@ -202,7 +203,8 @@ pub(crate) fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Loc
// PDF/UA compliance of the structure hierarchy is checked
// elsewhere. While this doesn't make a lot of sense, just
// avoid crashing here.
let tag = TagKind::TOCI.into();
let tag = TagKind::TOCI
.with_location(Some(outline_entry.span().into_raw().get()));
gc.tags.push(TagNode::Group(tag, entry.nodes));
return;
};
@ -216,7 +218,8 @@ pub(crate) fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Loc
// PDF/UA compliance of the structure hierarchy is checked
// elsewhere. While this doesn't make a lot of sense, just
// avoid crashing here.
let tag = TagKind::TD(TableDataCell::new()).into();
let tag = TagKind::TD(TableDataCell::new())
.with_location(Some(cell.span().into_raw().get()));
gc.tags.push(TagNode::Group(tag, entry.nodes));
return;
};
@ -324,11 +327,13 @@ pub(crate) fn add_annotations(
annotations: Vec<LinkAnnotation>,
) {
for annotation in annotations.into_iter() {
let LinkAnnotation { id: _, placeholder, alt, quad_points, target } = annotation;
let LinkAnnotation { id: _, placeholder, alt, quad_points, target, span } =
annotation;
let annot = krilla::annotation::Annotation::new_link(
krilla::annotation::LinkAnnotation::new_with_quad_points(quad_points, target),
alt,
);
)
.with_location(Some(span.into_raw().get()));
let annot_id = page.add_tagged_annotation(annot);
gc.tags.placeholders.init(placeholder, Node::Leaf(annot_id));
}

View File

@ -9,6 +9,7 @@ use smallvec::SmallVec;
use typst_library::foundations::{Packed, Smart, StyleChain};
use typst_library::model::TableCell;
use typst_library::pdf::{TableCellKind, TableHeaderScope};
use typst_syntax::Span;
use crate::tags::{TableId, TagNode};
@ -57,7 +58,7 @@ impl TableCtx {
}
}
pub(crate) fn insert(&mut self, cell: &TableCell, nodes: Vec<TagNode>) {
pub(crate) fn insert(&mut self, cell: &Packed<TableCell>, nodes: Vec<TagNode>) {
let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!());
let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!());
let rowspan = cell.rowspan.get(StyleChain::default());
@ -92,6 +93,7 @@ impl TableCtx {
kind,
headers: SmallVec::new(),
nodes,
span: cell.span(),
});
}
@ -175,13 +177,14 @@ impl TableCtx {
.with_headers(cell.headers),
)
.with_id(Some(id))
.with_location(Some(cell.span.into_raw().get()))
}
TableCellKind::Footer | TableCellKind::Data => TagKind::TD(
TableDataCell::new()
.with_span(span)
.with_headers(cell.headers),
)
.into(),
.with_location(Some(cell.span.into_raw().get())),
};
Some(TagNode::Group(tag, cell.nodes))
})
@ -296,6 +299,7 @@ struct TableCtxCell {
kind: Smart<TableCellKind>,
headers: SmallVec<[TagId; 1]>,
nodes: Vec<TagNode>,
span: Span,
}
impl TableCtxCell {
@ -344,7 +348,7 @@ mod tests {
fn table<const SIZE: usize>(cells: [TableCell; SIZE]) -> TableCtx {
let mut table = TableCtx::new(TableId(324), Some("summary".into()));
for cell in cells {
table.insert(&cell, Vec::new());
table.insert(&Packed::new(cell), Vec::new());
}
table
}
@ -416,7 +420,9 @@ mod tests {
let id = table_cell_id(TableId(324), x, y);
let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y));
TagNode::Group(
TagKind::TH(TableHeaderCell::new(scope).with_headers(ids)).with_id(Some(id)),
TagKind::TH(TableHeaderCell::new(scope).with_headers(ids))
.with_id(Some(id))
.with_location(Some(Span::detached().into_raw().get())),
Vec::new(),
)
}
@ -424,7 +430,8 @@ mod tests {
fn td<const SIZE: usize>(headers: [(u32, u32); SIZE]) -> TagNode {
let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y));
TagNode::Group(
TagKind::TD(TableDataCell::new().with_headers(ids)).into(),
TagKind::TD(TableDataCell::new().with_headers(ids))
.with_location(Some(Span::detached().into_raw().get())),
Vec::new(),
)
}

View File

@ -242,7 +242,7 @@ fn category_page(resolver: &dyn Resolver, category: Category) -> PageModel {
items.push(CategoryItem {
name: group.name.clone(),
route: subpage.route.clone(),
oneliner: oneliner(docs).into(),
oneliner: oneliner(docs),
code: true,
});
children.push(subpage);
@ -296,7 +296,7 @@ fn category_page(resolver: &dyn Resolver, category: Category) -> PageModel {
items.push(CategoryItem {
name: name.into(),
route: subpage.route.clone(),
oneliner: oneliner(func.docs().unwrap_or_default()).into(),
oneliner: oneliner(func.docs().unwrap_or_default()),
code: true,
});
children.push(subpage);
@ -306,7 +306,7 @@ fn category_page(resolver: &dyn Resolver, category: Category) -> PageModel {
items.push(CategoryItem {
name: ty.short_name().into(),
route: subpage.route.clone(),
oneliner: oneliner(ty.docs()).into(),
oneliner: oneliner(ty.docs()),
code: true,
});
children.push(subpage);
@ -637,7 +637,7 @@ fn group_page(
let item = CategoryItem {
name: group.name.clone(),
route: model.route.clone(),
oneliner: oneliner(&group.details).into(),
oneliner: oneliner(&group.details),
code: false,
};
@ -772,8 +772,24 @@ pub fn urlify(title: &str) -> EcoString {
}
/// Extract the first line of documentation.
fn oneliner(docs: &str) -> &str {
docs.lines().next().unwrap_or_default()
fn oneliner(docs: &str) -> EcoString {
let paragraph = docs.split("\n\n").next().unwrap_or_default();
let mut depth = 0;
let mut period = false;
let mut end = paragraph.len();
for (i, c) in paragraph.char_indices() {
match c {
'(' | '[' | '{' => depth += 1,
')' | ']' | '}' => depth -= 1,
'.' if depth == 0 => period = true,
c if period && c.is_whitespace() && !docs[..i].ends_with("e.g.") => {
end = i;
break;
}
_ => period = false,
}
}
EcoString::from(&docs[..end]).replace("\r\n", " ").replace("\n", " ")
}
/// The order of types in the documentation.

View File

@ -86,7 +86,7 @@ pub struct FuncModel {
pub name: EcoString,
pub title: &'static str,
pub keywords: &'static [&'static str],
pub oneliner: &'static str,
pub oneliner: EcoString,
pub element: bool,
pub contextual: bool,
pub deprecation: Option<&'static str>,
@ -139,7 +139,7 @@ pub struct TypeModel {
pub name: &'static str,
pub title: &'static str,
pub keywords: &'static [&'static str],
pub oneliner: &'static str,
pub oneliner: EcoString,
pub details: Html,
pub constructor: Option<FuncModel>,
pub scope: Vec<FuncModel>,

View File

@ -127,6 +127,10 @@
checks = self'.checks;
inputsFrom = [ typst ];
buildInputs = with pkgs; [
rust-analyzer
];
packages = [
# A script for quickly running tests.
# See https://github.com/typst/typst/blob/main/tests/README.md#making-an-alias

View File

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<p><span style="font-variant-caps: small-caps">Test 012</span><br><span style="font-variant-caps: all-small-caps">Test 012</span></p>
</body>
</html>

View File

@ -11,6 +11,6 @@
#show smallcaps: set text(fill: red)
#smallcaps[Smallcaps]
--- smallcaps-all ---
--- smallcaps-all render html ---
#smallcaps(all: false)[Test 012] \
#smallcaps(all: true)[Test 012]