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]]
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

@ -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

@ -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

@ -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(),
)
}