Compare commits

...

2 Commits

Author SHA1 Message Date
Tobias Schmitz
5ada3bb3cd
WIP [no ci] 2025-05-22 18:33:42 +02:00
Tobias Schmitz
8a97c1ee57
feat: [draft] generate accessibility tag tree for headings 2025-05-21 09:11:03 +02:00
2 changed files with 58 additions and 5 deletions

View File

@ -185,6 +185,8 @@ fn layout_page_run_impl(
)?; )?;
// Layouts a single marginal. // Layouts a single marginal.
// TODO: add some sort of tag that indicates the marginals and use it to
// mark them as artifacts for PDF/UA.
let mut layout_marginal = |content: &Option<Content>, area, align| { let mut layout_marginal = |content: &Option<Content>, area, align| {
let Some(content) = content else { return Ok(None) }; let Some(content) = content else { return Ok(None) };
let aligned = content.clone().styled(AlignElem::set_alignment(align)); let aligned = content.clone().styled(AlignElem::set_alignment(align));

View File

@ -10,11 +10,12 @@ 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::{Node, SpanTag, Tag, TagGroup, TagTree};
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};
use typst_library::foundations::NativeElement; use typst_library::foundations::{NativeElement, StyleChain};
use typst_library::introspection::Location; use typst_library::introspection::{self, Location};
use typst_library::layout::{ use typst_library::layout::{
Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform, Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform,
}; };
@ -39,14 +40,18 @@ pub fn convert(
typst_document: &PagedDocument, typst_document: &PagedDocument,
options: &PdfOptions, options: &PdfOptions,
) -> SourceResult<Vec<u8>> { ) -> SourceResult<Vec<u8>> {
// HACK
let config = Configuration::new_with_validator(Validator::UA1);
let settings = SerializeSettings { let settings = SerializeSettings {
compress_content_streams: true, compress_content_streams: true,
no_device_cs: true, no_device_cs: true,
ascii_compatible: false, ascii_compatible: false,
xmp_metadata: true, xmp_metadata: true,
cmyk_profile: None, cmyk_profile: None,
configuration: options.standards.config, configuration: config,
enable_tagging: false, // TODO: Should we just set this to false? If set to `false` this will
// automatically be enabled if the `UA1` validator is used.
enable_tagging: true,
render_svg_glyph_fn: render_svg_glyph, render_svg_glyph_fn: render_svg_glyph,
}; };
@ -54,6 +59,7 @@ pub fn convert(
let page_index_converter = PageIndexConverter::new(typst_document, options); let page_index_converter = PageIndexConverter::new(typst_document, options);
let named_destinations = let named_destinations =
collect_named_destinations(typst_document, &page_index_converter); collect_named_destinations(typst_document, &page_index_converter);
let mut gc = GlobalContext::new( let mut gc = GlobalContext::new(
typst_document, typst_document,
options, options,
@ -67,6 +73,12 @@ pub fn convert(
document.set_outline(build_outline(&gc)); document.set_outline(build_outline(&gc));
document.set_metadata(build_metadata(&gc)); document.set_metadata(build_metadata(&gc));
let mut tag_tree = TagTree::new();
for tag in gc.tags.drain(..) {
tag_tree.push(tag);
}
document.set_tag_tree(tag_tree);
finish(document, gc, options.standards.config) finish(document, gc, options.standards.config)
} }
@ -105,6 +117,8 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul
let mut surface = page.surface(); let mut surface = page.surface();
let mut fc = FrameContext::new(typst_page.frame.size()); let mut fc = FrameContext::new(typst_page.frame.size());
// TODO: PDF/UA tags may not cross page boundaries: close tags left
// in the stack and reopen them on a new page
handle_frame( handle_frame(
&mut fc, &mut fc,
&typst_page.frame, &typst_page.frame,
@ -225,6 +239,8 @@ pub(crate) struct GlobalContext<'a> {
/// The languages used throughout the document. /// The languages used throughout the document.
pub(crate) languages: BTreeMap<Lang, usize>, pub(crate) languages: BTreeMap<Lang, usize>,
pub(crate) page_index_converter: PageIndexConverter, pub(crate) page_index_converter: PageIndexConverter,
pub(crate) tag_stack: Vec<Location>,
pub(crate) tags: Vec<Node>,
} }
impl<'a> GlobalContext<'a> { impl<'a> GlobalContext<'a> {
@ -244,6 +260,8 @@ impl<'a> GlobalContext<'a> {
image_spans: HashSet::new(), image_spans: HashSet::new(),
languages: BTreeMap::new(), languages: BTreeMap::new(),
page_index_converter, page_index_converter,
tag_stack: Vec::new(),
tags: Vec::new(),
} }
} }
} }
@ -279,7 +297,40 @@ pub(crate) fn handle_frame(
handle_image(gc, fc, image, *size, surface, *span)? handle_image(gc, fc, image, *size, surface, *span)?
} }
FrameItem::Link(d, s) => handle_link(fc, gc, d, *s), FrameItem::Link(d, s) => handle_link(fc, gc, d, *s),
FrameItem::Tag(_) => {} FrameItem::Tag(introspection::Tag::Start(elem)) => {
let Some(heading) = elem.to_packed::<HeadingElem>() else { continue };
let loc = heading.location().expect("heading element to have a location");
// TODO: PDF/UA "Logical Structure" should not include artifacts:
// mabye close all open tags before an artifact and reopen them after?
let level = heading.resolve_level(StyleChain::default());
let name = heading.body.plain_text().to_string();
let heading_id = surface
.start_tagged(krilla::tagging::ContentTag::Span(SpanTag::empty()));
let tag = match level.get() {
1 => Tag::H1(Some(name)),
2 => Tag::H2(Some(name)),
3 => Tag::H3(Some(name)),
4 => Tag::H4(Some(name)),
5 => Tag::H5(Some(name)),
_ => Tag::H6(Some(name)),
};
let mut tag_group = TagGroup::new(tag);
tag_group.push(Node::Leaf(heading_id));
// TODO: Keep track of the logical document hierarchy and build
// a proper tag tree.
gc.tags.push(Node::Group(tag_group));
gc.tag_stack.push(loc);
}
FrameItem::Tag(introspection::Tag::End(loc, _)) => {
// FIXME: support or split up content tags that span multiple pages
if gc.tag_stack.last() == Some(loc) {
surface.end_tagged();
gc.tag_stack.pop();
}
}
} }
fc.pop(); fc.pop();