Compare commits

...

9 Commits

Author SHA1 Message Date
Tobias Schmitz
b7ccf9717e
feat: error when both --disable-pdf-tags and --pdf-standard=ua-1 are passed 2025-07-24 18:32:57 +02:00
Tobias Schmitz
50a280b6c2
feat: avoid doing extra work when --disable-pdf-tags is passed 2025-07-24 18:24:06 +02:00
Tobias Schmitz
820ea27a41
feat: write BBox for Table, Formula, and Figure tags 2025-07-24 18:02:45 +02:00
Tobias Schmitz
71425fc2b3
feat: use a span tag for marked content sequences containing text 2025-07-23 16:46:34 +02:00
Tobias Schmitz
3c206702c4
Merge branch 'main' into pdf-accessibility 2025-07-23 16:26:37 +02:00
Tobias Schmitz
042330cd66
feat: add alt field to figure 2025-07-23 16:17:50 +02:00
Tobias Schmitz
7278d887cf
Fix bounding box computation for lines in curves (#6647)
Co-authored-by: Laurenz <laurmaedje@gmail.com>
2025-07-23 14:17:03 +00:00
Tobias Schmitz
3909bdae6b
refactor: format code 2025-07-23 12:50:30 +02:00
Tobias Schmitz
d1202d9617
refactor: update krilla 2025-07-23 12:50:30 +02:00
23 changed files with 468 additions and 163 deletions

4
Cargo.lock generated
View File

@ -1430,7 +1430,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.4.0"
source = "git+https://github.com/LaurenzV/krilla?branch=main#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd"
source = "git+https://github.com/LaurenzV/krilla?branch=main#9e825532895036c7dfb440710d19271c6ad0473a"
dependencies = [
"base64",
"bumpalo",
@ -1460,7 +1460,7 @@ dependencies = [
[[package]]
name = "krilla-svg"
version = "0.1.0"
source = "git+https://github.com/LaurenzV/krilla?branch=main#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd"
source = "git+https://github.com/LaurenzV/krilla?branch=main#9e825532895036c7dfb440710d19271c6ad0473a"
dependencies = [
"flate2",
"fontdb",

View File

@ -131,6 +131,12 @@ impl CompileConfig {
PageRanges::new(export_ranges.iter().map(|r| r.0.clone()).collect())
});
if args.disable_pdf_tags
&& args.pdf_standard.iter().any(|s| *s == PdfStandard::Ua_1)
{
bail!("cannot disable pdf tags when exporting a PDF/UA-1 document");
}
let pdf_standards = PdfStandards::new(
&args.pdf_standard.iter().copied().map(Into::into).collect::<Vec<_>>(),
)?;

View File

@ -5,7 +5,8 @@ use ecow::{EcoVec, eco_format};
use smallvec::smallvec;
use typst_library::diag::{At, SourceResult, bail};
use typst_library::foundations::{
dict, Content, Context, LinkMarker, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, Smart, StyleChain, Target
Content, Context, LinkMarker, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn,
Smart, StyleChain, Target, dict,
};
use typst_library::introspection::{Counter, Locator, LocatorLink};
use typst_library::layout::{
@ -281,7 +282,8 @@ const HEADING_RULE: ShowFn<HeadingElem> = |elem, engine, styles| {
const FIGURE_RULE: ShowFn<FigureElem> = |elem, _, styles| {
let span = elem.span();
let mut realized = PdfMarkerTag::FigureBody(elem.body.clone());
let mut realized =
PdfMarkerTag::FigureBody(elem.alt.get_cloned(styles), elem.body.clone());
// Build the caption, if any.
if let Some(caption) = elem.caption.get_cloned(styles) {

View File

@ -24,6 +24,7 @@ mod page;
mod place;
mod point;
mod ratio;
mod rect;
mod regions;
mod rel;
mod repeat;
@ -55,6 +56,7 @@ pub use self::page::*;
pub use self::place::*;
pub use self::point::*;
pub use self::ratio::*;
pub use self::rect::*;
pub use self::regions::*;
pub use self::rel::*;
pub use self::repeat::*;

View File

@ -0,0 +1,27 @@
use crate::layout::{Point, Size};
/// A rectangle in 2D.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub struct Rect {
/// The top left corner (minimum coordinate).
pub min: Point,
/// The bottom right corner (maximum coordinate).
pub max: Point,
}
impl Rect {
/// Create a new rectangle from the minimum/maximum coordinate.
pub fn new(min: Point, max: Point) -> Self {
Self { min, max }
}
/// Create a new rectangle from the position and size.
pub fn from_pos_size(pos: Point, size: Size) -> Self {
Self { min: pos, max: pos + size.to_point() }
}
/// Compute the size of the rectangle.
pub fn size(&self) -> Size {
Size::new(self.max.x - self.min.x, self.max.y - self.min.y)
}
}

View File

@ -103,6 +103,9 @@ use crate::visualize::ImageElem;
/// ```
#[elem(scope, Locatable, Synthesize, Count, ShowSet, Refable, Outlinable)]
pub struct FigureElem {
/// An alternative description of the figure.
pub alt: Option<EcoString>,
/// The content of the figure. Often, an [image].
#[required]
pub body: Content,

View File

@ -1,5 +1,6 @@
use std::num::NonZeroU32;
use ecow::EcoString;
use typst_macros::{Cast, elem, func};
use typst_utils::NonZeroExt;
@ -118,8 +119,8 @@ impl Construct for PdfMarkerTag {
}
macro_rules! pdf_marker_tag {
($(#[doc = $doc:expr] $variant:ident$(($($name:ident: $ty:ident)+))?,)+) => {
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
($(#[doc = $doc:expr] $variant:ident$(($($name:ident: $ty:ty)+))?,)+) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum PdfMarkerTagKind {
$(
#[doc = $doc]
@ -147,7 +148,7 @@ pdf_marker_tag! {
/// `TOC`
OutlineBody,
/// `Figure`
FigureBody,
FigureBody(alt: Option<EcoString>),
/// `L` bibliography list
Bibliography(numbered: bool),
/// `LBody` wrapping `BibEntry`

View File

@ -4,7 +4,7 @@ use std::ops::Range;
use ecow::EcoString;
use typst_syntax::Span;
use crate::layout::{Abs, Em};
use crate::layout::{Abs, Em, Point, Rect};
use crate::text::{Font, Lang, Region, is_default_ignorable};
use crate::visualize::{FixedStroke, Paint};
@ -40,6 +40,44 @@ impl TextItem {
pub fn height(&self) -> Abs {
self.glyphs.iter().map(|g| g.y_advance).sum::<Em>().at(self.size)
}
/// The bounding box of the text run.
pub fn bbox(&self) -> Rect {
let mut min = Point::splat(Abs::inf());
let mut max = Point::splat(-Abs::inf());
let mut cursor = Point::zero();
for glyph in self.glyphs.iter() {
let advance =
Point::new(glyph.x_advance.at(self.size), glyph.y_advance.at(self.size));
let offset =
Point::new(glyph.x_offset.at(self.size), glyph.y_offset.at(self.size));
if let Some(rect) =
self.font.ttf().glyph_bounding_box(ttf_parser::GlyphId(glyph.id))
{
let pos = cursor + offset;
let a = pos
+ Point::new(
self.font.to_em(rect.x_min).at(self.size),
self.font.to_em(rect.y_min).at(self.size),
);
let b = pos
+ Point::new(
self.font.to_em(rect.x_max).at(self.size),
self.font.to_em(rect.y_max).at(self.size),
);
min = min.min(a).min(b);
max = max.max(a).max(b);
}
cursor += advance;
}
// Text runs use a y-up coordinate system, in contrary to the default
// frame orientation.
min.y *= -1.0;
max.y *= -1.0;
Rect::new(min, max)
}
}
impl Debug for TextItem {

View File

@ -4,7 +4,7 @@ use typst_utils::Numeric;
use crate::diag::{HintedStrResult, HintedString, bail};
use crate::foundations::{Content, Packed, Smart, cast, elem};
use crate::layout::{Abs, Axes, Length, Point, Rel, Size};
use crate::layout::{Abs, Axes, Length, Point, Rect, Rel, Size};
use crate::visualize::{FillRule, Paint, Stroke};
use super::FixedStroke;
@ -474,28 +474,20 @@ impl Curve {
}
}
/// Computes the size of the bounding box of this curve.
pub fn bbox_size(&self) -> Size {
let mut min_x = Abs::inf();
let mut min_y = Abs::inf();
let mut max_x = -Abs::inf();
let mut max_y = -Abs::inf();
/// Computes the bounding box of this curve.
pub fn bbox(&self) -> Rect {
let mut min = Point::splat(Abs::inf());
let mut max = Point::splat(-Abs::inf());
let mut cursor = Point::zero();
for item in self.0.iter() {
match item {
CurveItem::Move(to) => {
min_x = min_x.min(cursor.x);
min_y = min_y.min(cursor.y);
max_x = max_x.max(cursor.x);
max_y = max_y.max(cursor.y);
cursor = *to;
}
CurveItem::Line(to) => {
min_x = min_x.min(cursor.x);
min_y = min_y.min(cursor.y);
max_x = max_x.max(cursor.x);
max_y = max_y.max(cursor.y);
min = min.min(cursor).min(*to);
max = max.max(cursor).max(*to);
cursor = *to;
}
CurveItem::Cubic(c0, c1, end) => {
@ -507,17 +499,22 @@ impl Curve {
);
let bbox = cubic.bounding_box();
min_x = min_x.min(Abs::pt(bbox.x0)).min(Abs::pt(bbox.x1));
min_y = min_y.min(Abs::pt(bbox.y0)).min(Abs::pt(bbox.y1));
max_x = max_x.max(Abs::pt(bbox.x0)).max(Abs::pt(bbox.x1));
max_y = max_y.max(Abs::pt(bbox.y0)).max(Abs::pt(bbox.y1));
min.x = min.x.min(Abs::pt(bbox.x0)).min(Abs::pt(bbox.x1));
min.y = min.y.min(Abs::pt(bbox.y0)).min(Abs::pt(bbox.y1));
max.x = max.x.max(Abs::pt(bbox.x0)).max(Abs::pt(bbox.x1));
max.y = max.y.max(Abs::pt(bbox.y0)).max(Abs::pt(bbox.y1));
cursor = *end;
}
CurveItem::Close => (),
}
}
Size::new(max_x - min_x, max_y - min_y)
Rect::new(min, max)
}
/// Computes the size of the bounding box of this curve.
pub fn bbox_size(&self) -> Size {
self.bbox().size()
}
}

View File

@ -1,5 +1,5 @@
use crate::foundations::{Cast, Content, Smart, elem};
use crate::layout::{Abs, Corners, Length, Point, Rel, Sides, Size, Sizing};
use crate::layout::{Abs, Corners, Length, Point, Rect, Rel, Sides, Size, Sizing};
use crate::visualize::{Curve, FixedStroke, Paint, Stroke};
/// A rectangle with optional content.
@ -375,6 +375,24 @@ impl Geometry {
}
}
/// The bounding box of the geometry.
pub fn bbox(&self) -> Rect {
match self {
Self::Line(end) => {
let min = end.min(Point::zero());
let max = end.max(Point::zero());
Rect::new(min, max)
}
Self::Rect(size) => {
let p = size.to_point();
let min = p.min(Point::zero());
let max = p.max(Point::zero());
Rect::new(min, max)
}
Self::Curve(curve) => curve.bbox(),
}
}
/// The bounding box of the geometry.
pub fn bbox_size(&self) -> Size {
match self {

View File

@ -107,7 +107,8 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul
let mut page = document.start_page_with(settings);
let mut surface = page.surface();
let mut fc = FrameContext::new(typst_page.frame.size());
let page_idx = gc.page_index_converter.pdf_page_index(i);
let mut fc = FrameContext::new(page_idx, typst_page.frame.size());
tags::page_start(gc, &mut surface);
@ -123,7 +124,7 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul
surface.finish();
tags::add_annotations(gc, &mut page, fc.link_annotations);
tags::add_link_annotations(gc, &mut page, fc.link_annotations);
}
}
@ -176,13 +177,17 @@ impl State {
/// Context needed for converting a single frame.
pub(crate) struct FrameContext {
/// The logical page index. This might be `None` if the page isn't exported,
/// of if the FrameContext has been built to convert a pattern.
pub(crate) page_idx: Option<usize>,
states: Vec<State>,
link_annotations: Vec<LinkAnnotation>,
}
impl FrameContext {
pub(crate) fn new(size: Size) -> Self {
pub(crate) fn new(page_idx: Option<usize>, size: Size) -> Self {
Self {
page_idx,
states: vec![State::new(size)],
link_annotations: Vec::new(),
}
@ -331,8 +336,6 @@ pub(crate) fn handle_group(
.and_then(|p| p.transform(fc.state().transform.to_krilla()));
if let Some(clip_path) = &clip_path {
let mut handle = tags::start_marked(gc, surface);
let surface = handle.surface();
surface.push_clip_path(clip_path, &krilla::paint::FillRule::NonZero);
}

View File

@ -9,7 +9,7 @@ use krilla::tagging::SpanTag;
use krilla_svg::{SurfaceExt, SvgSettings};
use typst_library::diag::{SourceResult, bail};
use typst_library::foundations::Smart;
use typst_library::layout::{Abs, Angle, Ratio, Size, Transform};
use typst_library::layout::{Abs, Angle, Point, Ratio, Rect, Size, Transform};
use typst_library::visualize::{
ExchangeFormat, Image, ImageKind, ImageScaling, PdfImage, RasterFormat, RasterImage,
};
@ -35,6 +35,8 @@ pub(crate) fn handle_image(
gc.image_spans.insert(span);
tags::update_bbox(gc, fc, || Rect::from_pos_size(Point::zero(), size));
let mut handle =
tags::start_span(gc, surface, SpanTag::empty().with_alt_text(image.alt()));
let surface = handle.surface();

View File

@ -50,11 +50,14 @@ pub(crate) fn handle_link(
}
};
let Some((link_id, link, link_nodes)) = gc.tags.stack.find_parent_link() else {
unreachable!("expected a link parent")
let (link_id, tagging_ctx) = match gc.tags.stack.find_parent_link() {
Some((link_id, link, nodes)) => (link_id, Some((link, nodes))),
None if gc.options.disable_tags => {
let link_id = gc.tags.next_link_id();
(link_id, None)
}
None => unreachable!("expected a link parent"),
};
let alt = link.alt.as_ref().map(EcoString::to_string);
let quad = to_quadrilateral(fc, size);
// Unfortunately quadpoints still aren't well supported by most PDF readers,
@ -65,14 +68,20 @@ pub(crate) fn handle_link(
Some(annotation) if should_use_quadpoints => annotation.quad_points.push(quad),
_ => {
let placeholder = gc.tags.placeholders.reserve();
link_nodes.push(TagNode::Placeholder(placeholder));
let (alt, span) = if let Some((link, nodes)) = tagging_ctx {
nodes.push(TagNode::Placeholder(placeholder));
let alt = link.alt.as_ref().map(EcoString::to_string);
(alt, link.span)
} else {
(None, Span::detached())
};
fc.push_link_annotation(LinkAnnotation {
id: link_id,
placeholder,
quad_points: vec![quad],
alt,
target,
span: link.span,
span,
});
}
}

View File

@ -127,7 +127,7 @@ fn convert_pattern(
let mut stream_builder = surface.stream_builder();
let mut surface = stream_builder.surface();
let mut fc = FrameContext::new(pattern.frame().size());
let mut fc = FrameContext::new(None, pattern.frame().size());
handle_frame(&mut fc, pattern.frame(), None, &mut surface, gc)?;
surface.finish();
let stream = stream_builder.finish();

View File

@ -17,6 +17,8 @@ pub(crate) fn handle_shape(
gc: &mut GlobalContext,
span: Span,
) -> SourceResult<()> {
tags::update_bbox(gc, fc, || shape.geometry.bbox());
let mut handle = tags::start_artifact(gc, surface, ArtifactKind::Other);
let surface = handle.surface();

View File

@ -1,4 +1,4 @@
use krilla::tagging::{ListNumbering, TagKind};
use krilla::tagging::{ListNumbering, Tag, TagKind};
use crate::tags::TagNode;
@ -61,7 +61,7 @@ impl ListCtx {
//
// So move the nested list out of the list item.
if let [_, TagNode::Group(tag, _)] = nodes.as_slice() {
if matches!(tag.kind, TagKind::L(_)) {
if let TagKind::L(_) = tag {
item.sub_list = nodes.pop();
}
}
@ -70,7 +70,7 @@ impl ListCtx {
}
pub(crate) fn push_bib_entry(&mut self, nodes: Vec<TagNode>) {
let nodes = vec![TagNode::Group(TagKind::BibEntry.into(), nodes)];
let nodes = vec![TagNode::Group(Tag::BibEntry.into(), nodes)];
// Bibliography lists cannot be nested, but may be missing labels.
if let Some(item) = self.items.last_mut().filter(|item| item.body.is_none()) {
item.body = Some(nodes);
@ -86,16 +86,16 @@ impl ListCtx {
pub(crate) fn build_list(self, mut nodes: Vec<TagNode>) -> TagNode {
for item in self.items.into_iter() {
nodes.push(TagNode::Group(
TagKind::LI.into(),
Tag::LI.into(),
vec![
TagNode::Group(TagKind::Lbl.into(), item.label),
TagNode::Group(TagKind::LBody.into(), item.body.unwrap_or_default()),
TagNode::Group(Tag::Lbl.into(), item.label),
TagNode::Group(Tag::LBody.into(), item.body.unwrap_or_default()),
],
));
if let Some(sub_list) = item.sub_list {
nodes.push(sub_list);
}
}
TagNode::Group(TagKind::L(self.numbering).into(), nodes)
TagNode::Group(Tag::L(self.numbering).into(), nodes)
}
}

View File

@ -1,15 +1,15 @@
use std::cell::OnceCell;
use std::collections::HashMap;
use std::num::NonZeroU32;
use std::ops::{Deref, DerefMut};
use ecow::EcoString;
use krilla::configure::Validator;
use krilla::geom as kg;
use krilla::page::Page;
use krilla::surface::Surface;
use krilla::tagging::{
ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, TableDataCell,
Tag, TagBuilder, TagGroup, TagKind, TagTree,
ArtifactType, BBox, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag,
TagGroup, TagKind, TagTree,
};
use typst_library::diag::SourceResult;
use typst_library::foundations::{
@ -17,7 +17,7 @@ use typst_library::foundations::{
SettableProperty, StyleChain,
};
use typst_library::introspection::Location;
use typst_library::layout::RepeatElem;
use typst_library::layout::{Abs, Point, Rect, RepeatElem};
use typst_library::math::EquationElem;
use typst_library::model::{
Destination, EnumElem, FigureCaption, FigureElem, FootnoteElem, FootnoteEntry,
@ -27,11 +27,12 @@ use typst_library::model::{
use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind};
use typst_library::visualize::ImageElem;
use crate::convert::GlobalContext;
use crate::convert::{FrameContext, GlobalContext};
use crate::link::LinkAnnotation;
use crate::tags::list::ListCtx;
use crate::tags::outline::OutlineCtx;
use crate::tags::table::TableCtx;
use crate::util::AbsExt;
mod list;
mod outline;
@ -42,6 +43,10 @@ pub(crate) fn handle_start(
surface: &mut Surface,
elem: &Content,
) -> SourceResult<()> {
if gc.options.disable_tags {
return Ok(());
}
if gc.tags.in_artifact.is_some() {
// Don't nest artifacts
return Ok(());
@ -58,16 +63,20 @@ pub(crate) fn handle_start(
return Ok(());
}
let tag: Tag = if let Some(tag) = elem.to_packed::<PdfMarkerTag>() {
match tag.kind {
let mut tag: TagKind = if let Some(tag) = elem.to_packed::<PdfMarkerTag>() {
match &tag.kind {
PdfMarkerTagKind::OutlineBody => {
push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new()))?;
return Ok(());
}
PdfMarkerTagKind::FigureBody => TagKind::Figure.into(),
PdfMarkerTagKind::FigureBody(alt) => {
let alt = alt.as_ref().map(|s| s.to_string());
push_stack(gc, loc, StackEntryKind::Figure(FigureCtx::new(alt)))?;
return Ok(());
}
PdfMarkerTagKind::Bibliography(numbered) => {
let numbering =
if numbered { ListNumbering::Decimal } else { ListNumbering::None };
if *numbered { ListNumbering::Decimal } else { ListNumbering::None };
push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering)))?;
return Ok(());
}
@ -83,7 +92,7 @@ pub(crate) fn handle_start(
push_stack(gc, loc, StackEntryKind::ListItemBody)?;
return Ok(());
}
PdfMarkerTagKind::Label => TagKind::Lbl.into(),
PdfMarkerTagKind::Label => Tag::Lbl.into(),
}
} else if let Some(entry) = elem.to_packed::<OutlineEntry>() {
push_stack(gc, loc, StackEntryKind::OutlineEntry(entry.clone()))?;
@ -105,27 +114,26 @@ pub(crate) fn handle_start(
// caption is contained within the figure like recommended for tables
// screen readers might ignore it.
// TODO: maybe this could be a `NonStruct` tag?
TagKind::P.into()
Tag::P.into()
} else if let Some(_) = elem.to_packed::<FigureCaption>() {
TagKind::Caption.into()
Tag::Caption.into()
} else if let Some(image) = elem.to_packed::<ImageElem>() {
let alt = image.alt.get_as_ref().map(|s| s.to_string());
let figure_tag = (gc.tags.stack.parent())
.and_then(StackEntryKind::as_standard_mut)
.filter(|tag| tag.kind == TagKind::Figure);
if let Some(figure_tag) = figure_tag {
if let Some(figure_ctx) = gc.tags.stack.parent_figure() {
// Set alt text of outer figure tag, if not present.
if figure_tag.alt_text.is_none() {
figure_tag.alt_text = alt;
if figure_ctx.alt.is_none() {
figure_ctx.alt = alt;
}
return Ok(());
} else {
TagKind::Figure.with_alt_text(alt)
push_stack(gc, loc, StackEntryKind::Figure(FigureCtx::new(alt)))?;
return Ok(());
}
} 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)
push_stack(gc, loc, StackEntryKind::Formula(FigureCtx::new(alt)))?;
return Ok(());
} 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());
@ -153,7 +161,7 @@ pub(crate) fn handle_start(
} else if let Some(heading) = elem.to_packed::<HeadingElem>() {
let level = heading.level().try_into().unwrap_or(NonZeroU32::MAX);
let name = heading.body.plain_text().to_string();
TagKind::Hn(level, Some(name)).into()
Tag::Hn(level, Some(name)).into()
} else if let Some(link) = elem.to_packed::<LinkMarker>() {
let link_id = gc.tags.next_link_id();
push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone()))?;
@ -168,21 +176,25 @@ pub(crate) fn handle_start(
} else if let Some(quote) = elem.to_packed::<QuoteElem>() {
// TODO: should the attribution be handled somehow?
if quote.block.get(StyleChain::default()) {
TagKind::BlockQuote.into()
Tag::BlockQuote.into()
} else {
TagKind::InlineQuote.into()
Tag::InlineQuote.into()
}
} else {
return Ok(());
};
let tag = tag.with_location(Some(elem.span().into_raw().get()));
tag.set_location(Some(elem.span().into_raw().get()));
push_stack(gc, loc, StackEntryKind::Standard(tag))?;
Ok(())
}
pub(crate) fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Location) {
if gc.options.disable_tags {
return;
}
if let Some((l, _)) = gc.tags.in_artifact {
if l == loc {
pop_artifact(gc, surface);
@ -203,9 +215,7 @@ 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
.with_location(Some(outline_entry.span().into_raw().get()));
gc.tags.push(TagNode::Group(tag, entry.nodes));
gc.tags.push(TagNode::Group(Tag::TOCI.into(), entry.nodes));
return;
};
@ -218,9 +228,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())
.with_location(Some(cell.span().into_raw().get()));
gc.tags.push(TagNode::Group(tag, entry.nodes));
let tag = Tag::TD.with_location(Some(cell.span().into_raw().get()));
gc.tags.push(TagNode::Group(tag.into(), entry.nodes));
return;
};
@ -243,13 +252,21 @@ pub(crate) fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Loc
list_ctx.push_bib_entry(entry.nodes);
return;
}
StackEntryKind::Figure(ctx) => {
let tag = Tag::Figure(ctx.alt).with_bbox(ctx.bbox.get());
TagNode::Group(tag.into(), entry.nodes)
}
StackEntryKind::Formula(ctx) => {
let tag = Tag::Formula(ctx.alt).with_bbox(ctx.bbox.get());
TagNode::Group(tag.into(), entry.nodes)
}
StackEntryKind::Link(_, link) => {
let alt = link.alt.as_ref().map(EcoString::to_string);
let tag = TagKind::Link.with_alt_text(alt);
let mut node = TagNode::Group(tag, entry.nodes);
let tag = Tag::Link.with_alt_text(alt);
let mut node = TagNode::Group(tag.into(), entry.nodes);
// Wrap link in reference tag, if it's not a url.
if let Destination::Position(_) | Destination::Location(_) = link.dest {
node = TagNode::Group(TagKind::Reference.into(), vec![node]);
node = TagNode::Group(Tag::Reference.into(), vec![node]);
}
node
}
@ -262,7 +279,7 @@ pub(crate) fn handle_end(gc: &mut GlobalContext, surface: &mut Surface, loc: Loc
StackEntryKind::FootNoteEntry(footnote_loc) => {
// Store footnotes separately so they can be inserted directly after
// the footnote reference in the reading order.
let tag = TagNode::Group(TagKind::Note.into(), entry.nodes);
let tag = TagNode::Group(Tag::Note.into(), entry.nodes);
gc.tags.footnotes.insert(footnote_loc, tag);
return;
}
@ -307,6 +324,10 @@ fn pop_artifact(gc: &mut GlobalContext, surface: &mut Surface) {
}
pub(crate) fn page_start(gc: &mut GlobalContext, surface: &mut Surface) {
if gc.options.disable_tags {
return;
}
if let Some((_, kind)) = gc.tags.in_artifact {
let ty = artifact_type(kind);
let id = surface.start_tagged(ContentTag::Artifact(ty));
@ -315,13 +336,17 @@ pub(crate) fn page_start(gc: &mut GlobalContext, surface: &mut Surface) {
}
pub(crate) fn page_end(gc: &mut GlobalContext, surface: &mut Surface) {
if gc.options.disable_tags {
return;
}
if gc.tags.in_artifact.is_some() {
surface.end_tagged();
}
}
/// Add all annotations that were found in the page frame.
pub(crate) fn add_annotations(
pub(crate) fn add_link_annotations(
gc: &mut GlobalContext,
page: &mut Page,
annotations: Vec<LinkAnnotation>,
@ -334,10 +359,27 @@ pub(crate) fn add_annotations(
alt,
)
.with_location(Some(span.into_raw().get()));
if gc.options.disable_tags {
page.add_annotation(annot);
} else {
let annot_id = page.add_tagged_annotation(annot);
gc.tags.placeholders.init(placeholder, Node::Leaf(annot_id));
}
}
}
pub(crate) fn update_bbox(
gc: &mut GlobalContext,
fc: &FrameContext,
compute_bbox: impl FnOnce() -> Rect,
) {
if gc.options.standards.config.validator() == Validator::UA1
&& let Some(bbox) = gc.tags.stack.find_parent_bbox()
{
bbox.expand_frame(fc, compute_bbox());
}
}
pub(crate) struct Tags {
/// The intermediary stack of nested tag groups.
@ -351,10 +393,10 @@ pub(crate) struct Tags {
pub(crate) footnotes: HashMap<Location, TagNode>,
pub(crate) in_artifact: Option<(Location, ArtifactKind)>,
/// Used to group multiple link annotations using quad points.
pub(crate) link_id: LinkId,
link_id: LinkId,
/// Used to generate IDs referenced in table `Headers` attributes.
/// The IDs must be document wide unique.
pub(crate) table_id: TableId,
table_id: TableId,
/// The output.
pub(crate) tree: Vec<TagNode>,
@ -423,7 +465,7 @@ impl Tags {
true
}
fn next_link_id(&mut self) -> LinkId {
pub(crate) fn next_link_id(&mut self) -> LinkId {
self.link_id.0 += 1;
self.link_id
}
@ -436,21 +478,36 @@ impl Tags {
pub(crate) struct TagStack(Vec<StackEntry>);
impl Deref for TagStack {
type Target = Vec<StackEntry>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for TagStack {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl TagStack {
pub(crate) fn last(&self) -> Option<&StackEntry> {
self.0.last()
}
pub(crate) fn last_mut(&mut self) -> Option<&mut StackEntry> {
self.0.last_mut()
}
pub(crate) fn push(&mut self, entry: StackEntry) {
self.0.push(entry);
}
pub(crate) fn pop_if(
&mut self,
predicate: impl FnMut(&mut StackEntry) -> bool,
) -> Option<StackEntry> {
let entry = self.0.pop_if(predicate)?;
// TODO: If tags of the items were overlapping, only updating the
// direct parent bounding box might produce too large bounding boxes.
if let Some((page_idx, rect)) = entry.kind.bbox().and_then(|b| b.rect)
&& let Some(parent) = self.find_parent_bbox()
{
parent.expand_page(page_idx, rect);
}
Some(entry)
}
pub(crate) fn parent(&mut self) -> Option<&mut StackEntryKind> {
self.0.last_mut().map(|e| &mut e.kind)
}
@ -463,6 +520,10 @@ impl TagStack {
self.parent()?.as_list_mut()
}
pub(crate) fn parent_figure(&mut self) -> Option<&mut FigureCtx> {
self.parent()?.as_figure_mut()
}
pub(crate) fn parent_outline(
&mut self,
) -> Option<(&mut OutlineCtx, &mut Vec<TagNode>)> {
@ -480,6 +541,11 @@ impl TagStack {
Some((link_id, link.as_ref(), &mut e.nodes))
})
}
/// Finds the first parent that has a bounding box.
pub(crate) fn find_parent_bbox(&mut self) -> Option<&mut BBoxCtx> {
self.0.iter_mut().rev().find_map(|e| e.kind.bbox_mut())
}
}
pub(crate) struct Placeholders(Vec<OnceCell<Node>>);
@ -518,7 +584,7 @@ pub(crate) struct StackEntry {
#[derive(Debug)]
pub(crate) enum StackEntryKind {
Standard(Tag),
Standard(TagKind),
Outline(OutlineCtx),
OutlineEntry(Packed<OutlineEntry>),
Table(TableCtx),
@ -527,6 +593,8 @@ pub(crate) enum StackEntryKind {
ListItemLabel,
ListItemBody,
BibEntry,
Figure(FigureCtx),
Formula(FigureCtx),
Link(LinkId, Packed<LinkMarker>),
/// The footnote reference in the text.
FootNoteRef,
@ -536,10 +604,6 @@ pub(crate) enum StackEntryKind {
}
impl StackEntryKind {
pub(crate) fn as_standard_mut(&mut self) -> Option<&mut Tag> {
if let Self::Standard(v) = self { Some(v) } else { None }
}
pub(crate) fn as_outline_mut(&mut self) -> Option<&mut OutlineCtx> {
if let Self::Outline(v) = self { Some(v) } else { None }
}
@ -552,14 +616,123 @@ impl StackEntryKind {
if let Self::List(v) = self { Some(v) } else { None }
}
pub(crate) fn as_figure_mut(&mut self) -> Option<&mut FigureCtx> {
if let Self::Figure(v) = self { Some(v) } else { None }
}
pub(crate) fn as_link(&self) -> Option<(LinkId, &Packed<LinkMarker>)> {
if let Self::Link(id, link) = self { Some((*id, link)) } else { None }
}
pub(crate) fn bbox(&self) -> Option<&BBoxCtx> {
match self {
Self::Table(ctx) => Some(&ctx.bbox),
Self::Figure(ctx) => Some(&ctx.bbox),
Self::Formula(ctx) => Some(&ctx.bbox),
_ => None,
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) fn bbox_mut(&mut self) -> Option<&mut BBoxCtx> {
match self {
Self::Table(ctx) => Some(&mut ctx.bbox),
Self::Figure(ctx) => Some(&mut ctx.bbox),
Self::Formula(ctx) => Some(&mut ctx.bbox),
_ => None,
}
}
}
/// Figure/Formula context
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct FigureCtx {
alt: Option<String>,
bbox: BBoxCtx,
}
impl FigureCtx {
fn new(alt: Option<String>) -> Self {
Self { alt, bbox: BBoxCtx::new() }
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct BBoxCtx {
rect: Option<(usize, Rect)>,
multi_page: bool,
}
impl BBoxCtx {
pub(crate) fn new() -> Self {
Self { rect: None, multi_page: false }
}
/// Expand the bounding box with a `rect` relative to the current frame
/// context transform.
pub(crate) fn expand_frame(&mut self, fc: &FrameContext, rect: Rect) {
let Some(page_idx) = fc.page_idx else { return };
if self.multi_page {
return;
}
let (idx, bbox) = self.rect.get_or_insert((
page_idx,
Rect::new(Point::splat(Abs::inf()), Point::splat(-Abs::inf())),
));
if *idx != page_idx {
self.multi_page = true;
self.rect = None;
return;
}
let size = rect.size();
for point in [
rect.min,
rect.min + Point::with_x(size.x),
rect.min + Point::with_y(size.y),
rect.max,
] {
let p = point.transform(fc.state().transform());
bbox.min = bbox.min.min(p);
bbox.max = bbox.max.max(p);
}
}
/// Expand the bounding box with a rectangle that's already transformed into
/// page coordinates.
pub(crate) fn expand_page(&mut self, page_idx: usize, rect: Rect) {
if self.multi_page {
return;
}
let (idx, bbox) = self.rect.get_or_insert((
page_idx,
Rect::new(Point::splat(Abs::inf()), Point::splat(-Abs::inf())),
));
if *idx != page_idx {
self.multi_page = true;
self.rect = None;
return;
}
bbox.min = bbox.min.min(rect.min);
bbox.max = bbox.max.max(rect.max);
}
pub(crate) fn get(&self) -> Option<BBox> {
let (page_idx, rect) = self.rect?;
let rect = kg::Rect::from_ltrb(
rect.min.x.to_f32(),
rect.min.y.to_f32(),
rect.max.x.to_f32(),
rect.max.y.to_f32(),
)
.unwrap();
Some(BBox::new(page_idx as usize, rect))
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum TagNode {
Group(Tag, Vec<TagNode>),
Group(TagKind, Vec<TagNode>),
Leaf(Identifier),
/// Allows inserting a placeholder into the tag tree.
/// Currently used for [`krilla::page::Page::add_tagged_annotation`].
@ -592,15 +765,6 @@ impl<'a> TagHandle<'a, '_> {
}
}
/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`]
/// when dropped.
pub(crate) fn start_marked<'a, 'b>(
gc: &mut GlobalContext,
surface: &'b mut Surface<'a>,
) -> TagHandle<'a, 'b> {
start_content(gc, surface, ContentTag::Other)
}
/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`]
/// when dropped.
pub(crate) fn start_span<'a, 'b>(
@ -627,6 +791,10 @@ fn start_content<'a, 'b>(
surface: &'b mut Surface<'a>,
content: ContentTag,
) -> TagHandle<'a, 'b> {
if gc.options.disable_tags {
return TagHandle { surface, started: false };
}
let content = if gc.tags.in_artifact.is_some() {
return TagHandle { surface, started: false };
} else if let Some(StackEntryKind::Table(_)) = gc.tags.stack.last().map(|e| &e.kind) {

View File

@ -1,4 +1,4 @@
use krilla::tagging::TagKind;
use krilla::tagging::Tag;
use typst_library::foundations::Packed;
use typst_library::model::OutlineEntry;
@ -29,7 +29,7 @@ impl OutlineCtx {
}
}
let section_entry = TagNode::Group(TagKind::TOCI.into(), nodes);
let section_entry = TagNode::Group(Tag::TOCI.into(), nodes);
self.push(outline_nodes, section_entry);
}
@ -49,7 +49,7 @@ impl OutlineCtx {
while !self.stack.is_empty() {
self.finish_section(&mut outline_nodes);
}
TagNode::Group(TagKind::TOC.into(), outline_nodes)
TagNode::Group(Tag::TOC.into(), outline_nodes)
}
}
@ -68,6 +68,6 @@ impl OutlineSection {
}
fn into_tag(self) -> TagNode {
TagNode::Group(TagKind::TOC.into(), self.entries)
TagNode::Group(Tag::TOC.into(), self.entries)
}
}

View File

@ -2,28 +2,33 @@ use std::io::Write as _;
use std::num::NonZeroU32;
use az::SaturatingAs;
use krilla::tagging::{
TableCellSpan, TableDataCell, TableHeaderCell, TagBuilder, TagId, TagKind,
};
use krilla::tagging::{Tag, TagId, TagKind};
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};
use crate::tags::{BBoxCtx, TableId, TagNode};
#[derive(Debug)]
pub(crate) struct TableCtx {
pub(crate) id: TableId,
pub(crate) summary: Option<String>,
pub(crate) bbox: BBoxCtx,
rows: Vec<Vec<GridCell>>,
min_width: usize,
}
impl TableCtx {
pub(crate) fn new(id: TableId, summary: Option<String>) -> Self {
Self { id, summary, rows: Vec::new(), min_width: 0 }
Self {
id,
summary,
bbox: BBoxCtx::new(),
rows: Vec::new(),
min_width: 0,
}
}
fn get(&self, x: usize, y: usize) -> Option<&TableCtxCell> {
@ -101,7 +106,7 @@ impl TableCtx {
// Table layouting ensures that there are no overlapping cells, and that
// any gaps left by the user are filled with empty cells.
if self.rows.is_empty() {
return TagNode::Group(TagKind::Table(self.summary).into(), nodes);
return TagNode::Group(Tag::Table.with_summary(self.summary).into(), nodes);
}
let height = self.rows.len();
let width = self.rows[0].len();
@ -166,31 +171,32 @@ impl TableCtx {
.into_iter()
.filter_map(|cell| {
let cell = cell.into_cell()?;
let span = TableCellSpan { rows: cell.rowspan, cols: cell.colspan };
let rowspan = (cell.rowspan.get() != 1).then_some(cell.rowspan);
let colspan = (cell.colspan.get() != 1).then_some(cell.colspan);
let tag = match cell.unwrap_kind() {
TableCellKind::Header(_, scope) => {
let id = table_cell_id(self.id, cell.x, cell.y);
let scope = table_header_scope(scope);
TagKind::TH(
TableHeaderCell::new(scope)
.with_span(span)
.with_headers(cell.headers),
)
Tag::TH(scope)
.with_id(Some(id))
.with_headers(cell.headers)
.with_row_span(rowspan)
.with_col_span(colspan)
.with_location(Some(cell.span.into_raw().get()))
.into()
}
TableCellKind::Footer | TableCellKind::Data => TagKind::TD(
TableDataCell::new()
.with_span(span)
.with_headers(cell.headers),
)
.with_location(Some(cell.span.into_raw().get())),
TableCellKind::Footer | TableCellKind::Data => Tag::TD
.with_headers(cell.headers)
.with_row_span(rowspan)
.with_col_span(colspan)
.with_location(Some(cell.span.into_raw().get()))
.into(),
};
Some(TagNode::Group(tag, cell.nodes))
})
.collect();
let row = TagNode::Group(TagKind::TR.into(), row_nodes);
let row = TagNode::Group(Tag::TR.into(), row_nodes);
// Push the `TR` tags directly.
if !gen_row_groups {
@ -200,10 +206,10 @@ impl TableCtx {
// Generate row groups.
if !should_group_rows(chunk_kind, row_kind) {
let tag = match chunk_kind {
TableCellKind::Header(..) => TagKind::THead,
TableCellKind::Footer => TagKind::TFoot,
TableCellKind::Data => TagKind::TBody,
let tag: TagKind = match chunk_kind {
TableCellKind::Header(..) => Tag::THead.into(),
TableCellKind::Footer => Tag::TFoot.into(),
TableCellKind::Data => Tag::TBody.into(),
};
nodes.push(TagNode::Group(tag.into(), std::mem::take(&mut row_chunk)));
@ -213,15 +219,19 @@ impl TableCtx {
}
if !row_chunk.is_empty() {
let tag = match chunk_kind {
TableCellKind::Header(..) => TagKind::THead,
TableCellKind::Footer => TagKind::TFoot,
TableCellKind::Data => TagKind::TBody,
let tag: TagKind = match chunk_kind {
TableCellKind::Header(..) => Tag::THead.into(),
TableCellKind::Footer => Tag::TFoot.into(),
TableCellKind::Data => Tag::TBody.into(),
};
nodes.push(TagNode::Group(tag.into(), row_chunk));
}
TagNode::Group(TagKind::Table(self.summary).into(), nodes)
let tag = Tag::Table
.with_summary(self.summary)
.with_bbox(self.bbox.get())
.into();
TagNode::Group(tag, nodes)
}
fn resolve_cell_headers<F>(
@ -379,24 +389,24 @@ mod tests {
}
fn table_tag<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
let tag = TagKind::Table(Some("summary".into()));
let tag = Tag::Table.with_summary(Some("summary".into()));
TagNode::Group(tag.into(), nodes.into())
}
fn thead<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::THead.into(), nodes.into())
TagNode::Group(Tag::THead.into(), nodes.into())
}
fn tbody<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::TBody.into(), nodes.into())
TagNode::Group(Tag::TBody.into(), nodes.into())
}
fn tfoot<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::TFoot.into(), nodes.into())
TagNode::Group(Tag::TFoot.into(), nodes.into())
}
fn trow<const SIZE: usize>(nodes: [TagNode; SIZE]) -> TagNode {
TagNode::Group(TagKind::TR.into(), nodes.into())
TagNode::Group(Tag::TR.into(), nodes.into())
}
fn th<const SIZE: usize>(
@ -408,9 +418,11 @@ 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))
Tag::TH(scope)
.with_id(Some(id))
.with_location(Some(Span::detached().into_raw().get())),
.with_headers(ids)
.with_location(Some(Span::detached().into_raw().get()))
.into(),
Vec::new(),
)
}
@ -418,8 +430,10 @@ 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))
.with_location(Some(Span::detached().into_raw().get())),
Tag::TD
.with_headers(ids)
.with_location(Some(Span::detached().into_raw().get()))
.into(),
Vec::new(),
)
}

View File

@ -3,6 +3,7 @@ use std::sync::Arc;
use bytemuck::TransparentWrapper;
use krilla::surface::{Location, Surface};
use krilla::tagging::SpanTag;
use krilla::text::GlyphId;
use typst_library::diag::{SourceResult, bail};
use typst_library::layout::Size;
@ -23,7 +24,9 @@ pub(crate) fn handle_text(
) -> SourceResult<()> {
*gc.languages.entry(t.lang).or_insert(0) += t.glyphs.len();
let mut handle = tags::start_marked(gc, surface);
tags::update_bbox(gc, fc, || t.bbox());
let mut handle = tags::start_span(gc, surface, SpanTag::empty());
let surface = handle.surface();
let font = convert_font(gc, t.font.clone())?;

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -130,6 +130,16 @@
down, up, down, up, down,
)
--- curve-stroke-gradient-sharp ---
#set page(width: auto)
#let down = curve.line((40pt, 40pt), relative: true)
#let up = curve.line((40pt, -40pt), relative: true)
#curve(
stroke: 4pt + gradient.linear(red, blue).sharp(3),
down, up, down, up, down,
)
--- curve-fill-rule ---
#stack(
dir: ltr,