From 38dd4a36ea97db8a8de65fa718dcf93794c4c7e5 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Wed, 28 May 2025 17:47:35 +0200 Subject: [PATCH] feat: [WIP] allow specifying alt text for links skip-checks:true # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # Date: Wed May 28 17:47:35 2025 +0200 # # On branch pdf-accessibility # Your branch and 'origin/pdf-accessibility' have diverged, # and have 11 and 5 different commits each, respectively. # # Changes to be committed: # modified: crates/typst-ide/src/jump.rs # modified: crates/typst-layout/src/flow/distribute.rs # modified: crates/typst-layout/src/modifiers.rs # modified: crates/typst-library/src/foundations/content.rs # modified: crates/typst-library/src/layout/frame.rs # modified: crates/typst-library/src/model/bibliography.rs # modified: crates/typst-library/src/model/footnote.rs # modified: crates/typst-library/src/model/link.rs # modified: crates/typst-library/src/model/outline.rs # modified: crates/typst-library/src/model/reference.rs # modified: crates/typst-pdf/src/convert.rs # modified: crates/typst-pdf/src/link.rs # modified: crates/typst-render/src/lib.rs # modified: crates/typst-svg/src/lib.rs # modified: tests/src/run.rs # --- crates/typst-ide/src/jump.rs | 2 +- crates/typst-layout/src/flow/distribute.rs | 2 +- crates/typst-layout/src/modifiers.rs | 6 +- .../typst-library/src/foundations/content.rs | 5 +- crates/typst-library/src/layout/frame.rs | 5 +- .../typst-library/src/model/bibliography.rs | 6 +- crates/typst-library/src/model/footnote.rs | 6 +- crates/typst-library/src/model/link.rs | 8 +- crates/typst-library/src/model/outline.rs | 24 ++++-- crates/typst-library/src/model/reference.rs | 3 +- crates/typst-pdf/src/convert.rs | 6 +- crates/typst-pdf/src/link.rs | 76 +++++++------------ crates/typst-render/src/lib.rs | 2 +- crates/typst-svg/src/lib.rs | 4 +- tests/src/run.rs | 2 +- 15 files changed, 84 insertions(+), 73 deletions(-) diff --git a/crates/typst-ide/src/jump.rs b/crates/typst-ide/src/jump.rs index b29bc4a48..0f9f84ff7 100644 --- a/crates/typst-ide/src/jump.rs +++ b/crates/typst-ide/src/jump.rs @@ -36,7 +36,7 @@ pub fn jump_from_click( ) -> Option { // Try to find a link first. for (pos, item) in frame.items() { - if let FrameItem::Link(dest, size) = item { + if let FrameItem::Link(_, dest, size) = item { if is_in_rect(*pos, *size, click) { return Some(match dest { Destination::Url(url) => Jump::Url(url.clone()), diff --git a/crates/typst-layout/src/flow/distribute.rs b/crates/typst-layout/src/flow/distribute.rs index f504d22e7..108a8d651 100644 --- a/crates/typst-layout/src/flow/distribute.rs +++ b/crates/typst-layout/src/flow/distribute.rs @@ -93,7 +93,7 @@ impl Item<'_, '_> { Self::Frame(frame, _) => { frame.size().is_zero() && frame.items().all(|(_, item)| { - matches!(item, FrameItem::Link(_, _) | FrameItem::Tag(_)) + matches!(item, FrameItem::Link(..) | FrameItem::Tag(_)) }) } Self::Placed(_, placed) => !placed.float, diff --git a/crates/typst-layout/src/modifiers.rs b/crates/typst-layout/src/modifiers.rs index b0371d63e..a7d882617 100644 --- a/crates/typst-layout/src/modifiers.rs +++ b/crates/typst-layout/src/modifiers.rs @@ -1,3 +1,4 @@ +use ecow::EcoString; use typst_library::foundations::StyleChain; use typst_library::layout::{Abs, Fragment, Frame, FrameItem, HideElem, Point, Sides}; use typst_library::model::{Destination, LinkElem, ParElem}; @@ -21,6 +22,7 @@ use typst_library::model::{Destination, LinkElem, ParElem}; pub struct FrameModifiers { /// A destination to link to. dest: Option, + alt: Option, /// Whether the contents of the frame should be hidden. hidden: bool, } @@ -28,8 +30,10 @@ pub struct FrameModifiers { impl FrameModifiers { /// Retrieve all modifications that should be applied per-frame. pub fn get_in(styles: StyleChain) -> Self { + // TODO: maybe verify that an alt text was provided here Self { dest: LinkElem::current_in(styles), + alt: LinkElem::alt_in(styles), hidden: HideElem::hidden_in(styles), } } @@ -102,7 +106,7 @@ fn modify_frame( pos.x -= outset.left; size += outset.sum_by_axis(); } - frame.push(pos, FrameItem::Link(dest.clone(), size)); + frame.push(pos, FrameItem::Link(modifiers.alt.clone(), dest.clone(), size)); } if modifiers.hidden { diff --git a/crates/typst-library/src/foundations/content.rs b/crates/typst-library/src/foundations/content.rs index 1855bb70b..278d49401 100644 --- a/crates/typst-library/src/foundations/content.rs +++ b/crates/typst-library/src/foundations/content.rs @@ -503,8 +503,9 @@ impl Content { } /// Link the content somewhere. - pub fn linked(self, dest: Destination) -> Self { - self.styled(LinkElem::set_current(Some(dest))) + pub fn linked(self, alt: Option, dest: Destination) -> Self { + self.styled(LinkElem::set_alt(alt)) + .styled(LinkElem::set_current(Some(dest))) } /// Set alignments for this content. diff --git a/crates/typst-library/src/layout/frame.rs b/crates/typst-library/src/layout/frame.rs index a26a7d0ef..5b9d1f1a7 100644 --- a/crates/typst-library/src/layout/frame.rs +++ b/crates/typst-library/src/layout/frame.rs @@ -4,6 +4,7 @@ use std::fmt::{self, Debug, Formatter}; use std::num::NonZeroUsize; use std::sync::Arc; +use ecow::EcoString; use typst_syntax::Span; use typst_utils::{LazyHash, Numeric}; @@ -473,7 +474,7 @@ pub enum FrameItem { /// An image and its size. Image(Image, Size, Span), /// An internal or external link to a destination. - Link(Destination, Size), + Link(Option, Destination, Size), /// An introspectable element that produced something within this frame. Tag(Tag), } @@ -485,7 +486,7 @@ impl Debug for FrameItem { Self::Text(text) => write!(f, "{text:?}"), Self::Shape(shape, _) => write!(f, "{shape:?}"), Self::Image(image, _, _) => write!(f, "{image:?}"), - Self::Link(dest, _) => write!(f, "Link({dest:?})"), + Self::Link(alt, dest, _) => write!(f, "Link({alt:?}, {dest:?})"), Self::Tag(tag) => write!(f, "{tag:?}"), } } diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 51e3b03b0..bf1da2391 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -852,7 +852,8 @@ impl<'a> Generator<'a> { renderer.display_elem_child(elem, &mut None, false)?; if let Some(location) = first_occurrences.get(item.key.as_str()) { let dest = Destination::Location(*location); - content = content.linked(dest); + // TODO: accept user supplied alt text + content = content.linked(None, dest); } StrResult::Ok(content) }) @@ -987,7 +988,8 @@ impl ElemRenderer<'_> { if let Some(hayagriva::ElemMeta::Entry(i)) = elem.meta { if let Some(location) = (self.link)(i) { let dest = Destination::Location(location); - content = content.linked(dest); + // TODO: accept user supplied alt text + content = content.linked(None, dest); } } diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs index 773f67467..af6664cb9 100644 --- a/crates/typst-library/src/model/footnote.rs +++ b/crates/typst-library/src/model/footnote.rs @@ -147,7 +147,8 @@ impl Show for Packed { let sup = SuperElem::new(num).pack().spanned(span); let loc = loc.variant(1); // Add zero-width weak spacing to make the footnote "sticky". - Ok(HElem::hole().pack() + sup.linked(Destination::Location(loc))) + // TODO: accept user supplied alt text + Ok(HElem::hole().pack() + sup.linked(None, Destination::Location(loc))) } } @@ -296,7 +297,8 @@ impl Show for Packed { let sup = SuperElem::new(num) .pack() .spanned(span) - .linked(Destination::Location(loc)) + // TODO: accept user supplied alt text + .linked(None, Destination::Location(loc)) .located(loc.variant(1)); Ok(Content::sequence([ diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs index 3d9dc5e55..d64192f29 100644 --- a/crates/typst-library/src/model/link.rs +++ b/crates/typst-library/src/model/link.rs @@ -40,6 +40,9 @@ use crate::text::TextElem; /// `https://` is automatically turned into a link. #[elem(Locatable, Show)] pub struct LinkElem { + /// A text describing the link. + pub alt: Option, + /// The destination the link points to. /// /// - To link to web pages, `dest` should be a valid URL string. If the URL @@ -123,12 +126,13 @@ impl Show for Packed { body } } else { + let alt = self.alt(styles); match &self.dest { - LinkTarget::Dest(dest) => body.linked(dest.clone()), + LinkTarget::Dest(dest) => body.linked(alt, dest.clone()), LinkTarget::Label(label) => { let elem = engine.introspector.query_label(*label).at(self.span())?; let dest = Destination::Location(elem.location().unwrap()); - body.clone().linked(dest) + body.clone().linked(alt, dest) } } }) diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index 03d301135..48dd3025a 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -2,6 +2,7 @@ use std::num::NonZeroUsize; use std::str::FromStr; use comemo::{Track, Tracked}; +use ecow::eco_format; use smallvec::SmallVec; use typst_syntax::Span; use typst_utils::{Get, NonZeroExt}; @@ -17,8 +18,7 @@ use crate::introspection::{ Counter, CounterKey, Introspector, Locatable, Location, Locator, LocatorLink, }; use crate::layout::{ - Abs, Axes, BlockBody, BlockElem, BoxElem, Dir, Em, Fr, HElem, Length, Region, Rel, - RepeatElem, Sides, + Abs, Axes, BlockBody, BlockElem, BoxElem, Dir, Em, Fr, HElem, Length, PageElem, Region, Rel, RepeatElem, Sides }; use crate::math::EquationElem; use crate::model::{Destination, HeadingElem, NumberingPattern, ParElem, Refable}; @@ -422,7 +422,17 @@ impl Show for Packed { let context = context.track(); let prefix = self.prefix(engine, context, span)?; - let inner = self.inner(engine, context, span)?; + let body = self.body().at(span)?; + let page = self.page(engine, context, span)?; + let alt = { + // TODO: accept user supplied alt text + let prefix = prefix.as_ref().map(|p| p.plain_text()).unwrap_or_default(); + let body = body.plain_text(); + let page_str = PageElem::local_name_in(styles); + let page_nr = page.plain_text(); + eco_format!("{prefix} {body} {page_str} {page_nr}") + }; + let inner = self.inner(engine, context, span, body, page)?; let block = if self.element.is::() { let body = prefix.unwrap_or_default() + inner; BlockElem::new() @@ -434,7 +444,7 @@ impl Show for Packed { }; let loc = self.element_location().at(span)?; - Ok(block.linked(Destination::Location(loc))) + Ok(block.linked(Some(alt), Destination::Location(loc))) } } @@ -571,6 +581,8 @@ impl OutlineEntry { engine: &mut Engine, context: Tracked, span: Span, + body: Content, + page: Content, ) -> SourceResult { let styles = context.styles().at(span)?; @@ -591,7 +603,7 @@ impl OutlineEntry { seq.push(TextElem::packed("\u{202B}")); } - seq.push(self.body().at(span)?); + seq.push(body); if rtl { // "Pop Directional Formatting" @@ -616,7 +628,7 @@ impl OutlineEntry { // Add the page number. The word joiner in front ensures that the page // number doesn't stand alone in its line. seq.push(TextElem::packed("\u{2060}")); - seq.push(self.page(engine, context, span)?); + seq.push(page); Ok(Content::sequence(seq)) } diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index 7d44cccc0..3f3ece05e 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -309,7 +309,8 @@ fn show_reference( content = supplement + TextElem::packed("\u{a0}") + content; } - Ok(content.linked(Destination::Location(loc))) + // TODO: accept user supplied alt text + Ok(content.linked(None, Destination::Location(loc))) } /// Turn a reference into a citation. diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 00d3aad83..b72d4aef1 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::num::NonZeroU64; -use ecow::{eco_format, EcoVec}; +use ecow::{eco_format, EcoString, EcoVec}; use krilla::annotation::Annotation; use krilla::configure::{Configuration, ValidationError, Validator}; use krilla::destination::{NamedDestination, XyzDestination}; @@ -314,7 +314,9 @@ pub(crate) fn handle_frame( FrameItem::Image(image, size, span) => { handle_image(gc, fc, image, *size, surface, *span)? } - FrameItem::Link(d, s) => handle_link(fc, gc, d, *s), + FrameItem::Link(alt, dest, size) => { + handle_link(fc, gc, alt.as_ref().map(EcoString::to_string), dest, *size) + } FrameItem::Tag(introspection::Tag::Start(elem)) => { handle_open_tag(gc, surface, elem) } diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs index a792778dd..6dfefbc11 100644 --- a/crates/typst-pdf/src/link.rs +++ b/crates/typst-pdf/src/link.rs @@ -2,7 +2,7 @@ use krilla::action::{Action, LinkAction}; use krilla::annotation::{Annotation, LinkAnnotation, Target}; use krilla::destination::XyzDestination; use krilla::geom::Rect; -use typst_library::layout::{Abs, Point, Size}; +use typst_library::layout::{Abs, Point, Position, Size}; use typst_library::model::Destination; use crate::convert::{FrameContext, GlobalContext}; @@ -12,6 +12,7 @@ use crate::util::{AbsExt, PointExt}; pub(crate) fn handle_link( fc: &mut FrameContext, gc: &mut GlobalContext, + alt: Option, dest: &Destination, size: Size, ) { @@ -45,61 +46,42 @@ pub(crate) fn handle_link( // TODO: Support quad points. - let placeholder = gc.tags.reserve_placeholder(); - gc.tags.push(TagNode::Placeholder(placeholder)); - - // TODO: add some way to add alt text to annotations. - // probably through [typst_layout::modifiers::FrameModifiers] - let pos = match dest { + let target = match dest { Destination::Url(u) => { - fc.push_annotation( - placeholder, - Annotation::new_link( - LinkAnnotation::new( - rect, - None, - Target::Action(Action::Link(LinkAction::new(u.to_string()))), - ), - Some(u.to_string()), - ), - ); - return; + Target::Action(Action::Link(LinkAction::new(u.to_string()))) } - Destination::Position(p) => *p, + Destination::Position(p) => match pos_to_target(gc, *p) { + Some(target) => target, + None => return, + }, Destination::Location(loc) => { if let Some(nd) = gc.loc_to_names.get(loc) { // If a named destination has been registered, it's already guaranteed to // not point to an excluded page. - fc.push_annotation( - placeholder, - LinkAnnotation::new( - rect, - None, - Target::Destination(krilla::destination::Destination::Named( - nd.clone(), - )), - ) - .into(), - ); - return; + Target::Destination(krilla::destination::Destination::Named(nd.clone())) } else { - gc.document.introspector.position(*loc) + let pos = gc.document.introspector.position(*loc); + match pos_to_target(gc, pos) { + Some(target) => target, + None => return, + } } } }; - let page_index = pos.page.get() - 1; - if let Some(index) = gc.page_index_converter.pdf_page_index(page_index) { - fc.push_annotation( - placeholder, - LinkAnnotation::new( - rect, - None, - Target::Destination(krilla::destination::Destination::Xyz( - XyzDestination::new(index, pos.point.to_krilla()), - )), - ) - .into(), - ); - } + let placeholder = gc.tags.reserve_placeholder(); + gc.tags.push(TagNode::Placeholder(placeholder)); + + fc.push_annotation( + placeholder, + Annotation::new_link(LinkAnnotation::new(rect, None, target), alt), + ); +} + +fn pos_to_target(gc: &mut GlobalContext, pos: Position) -> Option { + let page_index = pos.page.get() - 1; + let index = gc.page_index_converter.pdf_page_index(page_index)?; + + let dest = XyzDestination::new(index, pos.point.to_krilla()); + Some(Target::Destination(krilla::destination::Destination::Xyz(dest))) } diff --git a/crates/typst-render/src/lib.rs b/crates/typst-render/src/lib.rs index f43cd019b..3ecae4bad 100644 --- a/crates/typst-render/src/lib.rs +++ b/crates/typst-render/src/lib.rs @@ -167,7 +167,7 @@ fn render_frame(canvas: &mut sk::Pixmap, state: State, frame: &Frame) { FrameItem::Image(image, size, _) => { image::render_image(canvas, state.pre_translate(*pos), image, *size); } - FrameItem::Link(_, _) => {} + FrameItem::Link(..) => {} FrameItem::Tag(_) => {} } } diff --git a/crates/typst-svg/src/lib.rs b/crates/typst-svg/src/lib.rs index f4e81250f..91975ae37 100644 --- a/crates/typst-svg/src/lib.rs +++ b/crates/typst-svg/src/lib.rs @@ -207,7 +207,7 @@ impl SVGRenderer { for (pos, item) in frame.items() { // File size optimization. // TODO: SVGs could contain links, couldn't they? - if matches!(item, FrameItem::Link(_, _) | FrameItem::Tag(_)) { + if matches!(item, FrameItem::Link(..) | FrameItem::Tag(_)) { continue; } @@ -228,7 +228,7 @@ impl SVGRenderer { self.render_shape(state.pre_translate(*pos), shape) } FrameItem::Image(image, size, _) => self.render_image(image, size), - FrameItem::Link(_, _) => unreachable!(), + FrameItem::Link(..) => unreachable!(), FrameItem::Tag(_) => unreachable!(), }; diff --git a/tests/src/run.rs b/tests/src/run.rs index 4d08362cf..6d50ae63c 100644 --- a/tests/src/run.rs +++ b/tests/src/run.rs @@ -505,7 +505,7 @@ fn render_links(canvas: &mut sk::Pixmap, ts: sk::Transform, frame: &Frame) { let ts = ts.pre_concat(to_sk_transform(&group.transform)); render_links(canvas, ts, &group.frame); } - FrameItem::Link(_, size) => { + FrameItem::Link(_, _, size) => { let w = size.x.to_pt() as f32; let h = size.y.to_pt() as f32; let rect = sk::Rect::from_xywh(0.0, 0.0, w, h).unwrap();