feat: report spans for missing alt text and unknown/duplicate tag ids

This commit is contained in:
Tobias Schmitz 2025-07-18 15:39:19 +02:00
parent 99815f449c
commit d2105dcc35
No known key found for this signature in database
6 changed files with 85 additions and 29 deletions

4
Cargo.lock generated
View File

@ -1373,7 +1373,7 @@ dependencies = [
[[package]] [[package]]
name = "krilla" name = "krilla"
version = "0.4.0" 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 = [ dependencies = [
"base64", "base64",
"bumpalo", "bumpalo",
@ -1402,7 +1402,7 @@ dependencies = [
[[package]] [[package]]
name = "krilla-svg" name = "krilla-svg"
version = "0.1.0" 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 = [ dependencies = [
"flate2", "flate2",
"fontdb", "fontdb",

View File

@ -23,10 +23,10 @@ use serde::{Serialize, Serializer};
use typst_syntax::Span; use typst_syntax::Span;
use typst_utils::singleton; use typst_utils::singleton;
use crate::diag::{SourceResult, StrResult}; use crate::diag::{bail, SourceResult, StrResult};
use crate::engine::Engine; use crate::engine::Engine;
use crate::foundations::{ 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, RecipeIndex, Repr, Selector, Str, Style, StyleChain, Styles, Value,
}; };
use crate::introspection::{Locatable, Location}; use crate::introspection::{Locatable, Location};
@ -479,7 +479,7 @@ impl Content {
/// Link the content somewhere. /// Link the content somewhere.
pub fn linked(self, dest: Destination, alt: Option<EcoString>) -> Self { pub fn linked(self, dest: Destination, alt: Option<EcoString>) -> Self {
let span = self.span(); let span = self.span();
LinkMarker::new(self, dest.clone(), alt) LinkMarker::new(self, dest.clone(), alt, span)
.pack() .pack()
.spanned(span) .spanned(span)
.set(LinkElem::current, Some(dest)) .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. /// An element that associates the body of a link with the destination.
#[elem(Locatable)] #[elem(Locatable, Construct)]
pub struct LinkMarker { pub struct LinkMarker {
/// The content. /// The content.
#[internal]
#[required] #[required]
pub body: Content, pub body: Content,
#[internal]
#[required] #[required]
pub dest: Destination, pub dest: Destination,
#[internal]
#[required] #[required]
pub alt: Option<EcoString>, 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 { impl<T: NativeElement> IntoValue for T {

View File

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

View File

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

View File

@ -176,6 +176,7 @@ pub(crate) fn handle_start(
return Ok(()); return Ok(());
}; };
let tag = tag.with_location(Some(elem.span().into_raw().get()));
push_stack(gc, loc, StackEntryKind::Standard(tag))?; push_stack(gc, loc, StackEntryKind::Standard(tag))?;
Ok(()) 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 // PDF/UA compliance of the structure hierarchy is checked
// elsewhere. While this doesn't make a lot of sense, just // elsewhere. While this doesn't make a lot of sense, just
// avoid crashing here. // 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)); gc.tags.push(TagNode::Group(tag, entry.nodes));
return; 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 // PDF/UA compliance of the structure hierarchy is checked
// elsewhere. While this doesn't make a lot of sense, just // elsewhere. While this doesn't make a lot of sense, just
// avoid crashing here. // 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)); gc.tags.push(TagNode::Group(tag, entry.nodes));
return; return;
}; };
@ -324,11 +327,13 @@ pub(crate) fn add_annotations(
annotations: Vec<LinkAnnotation>, annotations: Vec<LinkAnnotation>,
) { ) {
for annotation in annotations.into_iter() { 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( let annot = krilla::annotation::Annotation::new_link(
krilla::annotation::LinkAnnotation::new_with_quad_points(quad_points, target), krilla::annotation::LinkAnnotation::new_with_quad_points(quad_points, target),
alt, alt,
); )
.with_location(Some(span.into_raw().get()));
let annot_id = page.add_tagged_annotation(annot); let annot_id = page.add_tagged_annotation(annot);
gc.tags.placeholders.init(placeholder, Node::Leaf(annot_id)); 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::foundations::{Packed, Smart, StyleChain};
use typst_library::model::TableCell; use typst_library::model::TableCell;
use typst_library::pdf::{TableCellKind, TableHeaderScope}; use typst_library::pdf::{TableCellKind, TableHeaderScope};
use typst_syntax::Span;
use crate::tags::{TableId, TagNode}; 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 x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!());
let y = cell.y.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()); let rowspan = cell.rowspan.get(StyleChain::default());
@ -92,6 +93,7 @@ impl TableCtx {
kind, kind,
headers: SmallVec::new(), headers: SmallVec::new(),
nodes, nodes,
span: cell.span(),
}); });
} }
@ -175,13 +177,14 @@ impl TableCtx {
.with_headers(cell.headers), .with_headers(cell.headers),
) )
.with_id(Some(id)) .with_id(Some(id))
.with_location(Some(cell.span.into_raw().get()))
} }
TableCellKind::Footer | TableCellKind::Data => TagKind::TD( TableCellKind::Footer | TableCellKind::Data => TagKind::TD(
TableDataCell::new() TableDataCell::new()
.with_span(span) .with_span(span)
.with_headers(cell.headers), .with_headers(cell.headers),
) )
.into(), .with_location(Some(cell.span.into_raw().get())),
}; };
Some(TagNode::Group(tag, cell.nodes)) Some(TagNode::Group(tag, cell.nodes))
}) })
@ -296,6 +299,7 @@ struct TableCtxCell {
kind: Smart<TableCellKind>, kind: Smart<TableCellKind>,
headers: SmallVec<[TagId; 1]>, headers: SmallVec<[TagId; 1]>,
nodes: Vec<TagNode>, nodes: Vec<TagNode>,
span: Span,
} }
impl TableCtxCell { impl TableCtxCell {
@ -344,7 +348,7 @@ mod tests {
fn table<const SIZE: usize>(cells: [TableCell; SIZE]) -> TableCtx { fn table<const SIZE: usize>(cells: [TableCell; SIZE]) -> TableCtx {
let mut table = TableCtx::new(TableId(324), Some("summary".into())); let mut table = TableCtx::new(TableId(324), Some("summary".into()));
for cell in cells { for cell in cells {
table.insert(&cell, Vec::new()); table.insert(&Packed::new(cell), Vec::new());
} }
table table
} }
@ -416,7 +420,9 @@ mod tests {
let id = table_cell_id(TableId(324), x, y); let id = table_cell_id(TableId(324), x, y);
let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y)); let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y));
TagNode::Group( 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(), Vec::new(),
) )
} }
@ -424,7 +430,8 @@ mod tests {
fn td<const SIZE: usize>(headers: [(u32, u32); SIZE]) -> TagNode { fn td<const SIZE: usize>(headers: [(u32, u32); SIZE]) -> TagNode {
let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y)); let ids = headers.map(|(x, y)| table_cell_id(TableId(324), x, y));
TagNode::Group( 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(), Vec::new(),
) )
} }