diff --git a/Cargo.lock b/Cargo.lock index 3e5fb87ee..7e83abd4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -592,6 +592,12 @@ dependencies = [ "syn", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "dirs" version = "6.0.0" @@ -1424,7 +1430,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.4.0" -source = "git+https://github.com/LaurenzV/krilla?rev=37b9a00#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd" +source = "git+https://github.com/LaurenzV/krilla?branch=main#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd" dependencies = [ "base64", "bumpalo", @@ -1454,7 +1460,7 @@ dependencies = [ [[package]] name = "krilla-svg" version = "0.1.0" -source = "git+https://github.com/LaurenzV/krilla?rev=37b9a00#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd" +source = "git+https://github.com/LaurenzV/krilla?branch=main#37b9a00bfac87ed0b347b7cf8e9d37a6f68fcccd" dependencies = [ "flate2", "fontdb", @@ -2040,6 +2046,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "proc-macro2" version = "1.0.93" @@ -3193,6 +3209,7 @@ dependencies = [ name = "typst-pdf" version = "0.13.1" dependencies = [ + "az", "bytemuck", "comemo", "ecow", @@ -3200,7 +3217,9 @@ dependencies = [ "infer", "krilla", "krilla-svg", + "pretty_assertions", "serde", + "smallvec", "typst-assets", "typst-library", "typst-macros", @@ -3867,6 +3886,12 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.7.5" diff --git a/Cargo.toml b/Cargo.toml index 500d116a6..792573f98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,8 +74,8 @@ image = { version = "0.25.5", default-features = false, features = ["png", "jpeg indexmap = { version = "2", features = ["serde"] } infer = { version = "0.19.0", default-features = false } kamadak-exif = "0.6" -krilla = { git = "https://github.com/LaurenzV/krilla", rev = "37b9a00", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } -krilla-svg = { git = "https://github.com/LaurenzV/krilla", rev = "37b9a00"} +krilla = { git = "https://github.com/LaurenzV/krilla", branch = "main", default-features = false, features = ["raster-images", "comemo", "rayon", "pdf"] } +krilla-svg = { git = "https://github.com/LaurenzV/krilla", branch = "main" } kurbo = "0.11" libfuzzer-sys = "0.4" lipsum = "0.9" @@ -93,6 +93,7 @@ phf = { version = "0.11", features = ["macros"] } pixglyph = "0.6" png = "0.17" portable-atomic = "1.6" +pretty_assertions = "1.4.1" proc-macro2 = "1" pulldown-cmark = "0.9" qcms = "0.3.0" diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 7459be0f2..092c09f4c 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -258,6 +258,13 @@ pub struct CompileArgs { #[arg(long = "pdf-standard", value_delimiter = ',')] pub pdf_standard: Vec, + /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF + /// document is written to provide a baseline of accessibility. In some + /// circumstances (for example when trying to reduce the size of a document) + /// it can be desirable to disable tagged PDF. + #[arg(long = "disable-pdf-tags")] + pub disable_pdf_tags: bool, + /// The PPI (pixels per inch) to use for PNG export. #[arg(long = "ppi", default_value_t = 144.0)] pub ppi: f32, @@ -518,6 +525,9 @@ pub enum PdfStandard { /// PDF/A-4e. #[value(name = "a-4e")] A_4e, + /// PDF/UA-1. + #[value(name = "ua-1")] + Ua_1, } display_possible_values!(PdfStandard); diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 30cf1473e..51b6e9a23 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -65,6 +65,8 @@ pub struct CompileConfig { pub open: Option>, /// A list of standards the PDF should conform to. pub pdf_standards: PdfStandards, + /// Whether to write PDF (accessibility) tags. + pub disable_pdf_tags: bool, /// A path to write a Makefile rule describing the current compilation. pub make_deps: Option, /// The PPI (pixels per inch) to use for PNG export. @@ -150,6 +152,7 @@ impl CompileConfig { output_format, pages, pdf_standards, + disable_pdf_tags: args.disable_pdf_tags, creation_timestamp: args.world.creation_timestamp, make_deps: args.make_deps.clone(), ppi: args.ppi, @@ -291,6 +294,7 @@ fn export_pdf(document: &PagedDocument, config: &CompileConfig) -> SourceResult< timestamp, page_ranges: config.pages.clone(), standards: config.pdf_standards.clone(), + disable_tags: config.disable_pdf_tags, }; let buffer = typst_pdf::pdf(document, &options)?; config @@ -775,6 +779,7 @@ impl From for typst_pdf::PdfStandard { PdfStandard::A_4 => typst_pdf::PdfStandard::A_4, PdfStandard::A_4f => typst_pdf::PdfStandard::A_4f, PdfStandard::A_4e => typst_pdf::PdfStandard::A_4e, + PdfStandard::Ua_1 => typst_pdf::PdfStandard::Ua_1, } } } diff --git a/crates/typst-layout/src/grid/layouter.rs b/crates/typst-layout/src/grid/layouter.rs index 2e8cc9e55..a76fabf06 100644 --- a/crates/typst-layout/src/grid/layouter.rs +++ b/crates/typst-layout/src/grid/layouter.rs @@ -1279,14 +1279,23 @@ impl<'a> GridLayouter<'a> { let frames = layout_cell(cell, engine, disambiguator, self.styles, pod)?.into_frames(); + // HACK: reconsider if this is the right decision + fn is_empty_frame(frame: &Frame) -> bool { + !frame.items().any(|(_, item)| match item { + FrameItem::Group(group) => is_empty_frame(&group.frame), + FrameItem::Tag(_) => false, + _ => true, + }) + } + // Skip the first region if one cell in it is empty. Then, // remeasure. if let Some([first, rest @ ..]) = frames.get(measurement_data.frames_in_previous_regions..) && can_skip && breakable - && first.is_empty() - && rest.iter().any(|frame| !frame.is_empty()) + && is_empty_frame(first) + && rest.iter().any(|frame| !is_empty_frame(frame)) { return Ok(None); } diff --git a/crates/typst-layout/src/lists.rs b/crates/typst-layout/src/lists.rs index 3a3ea8227..be50b9ba0 100644 --- a/crates/typst-layout/src/lists.rs +++ b/crates/typst-layout/src/lists.rs @@ -7,6 +7,7 @@ use typst_library::introspection::Locator; use typst_library::layout::grid::resolve::{Cell, CellGrid}; use typst_library::layout::{Axes, Fragment, HAlignment, Regions, Sizing, VAlignment}; use typst_library::model::{EnumElem, ListElem, Numbering, ParElem, ParbreakElem}; +use typst_library::pdf::PdfMarkerTag; use typst_library::text::TextElem; use crate::grid::GridLayouter; @@ -44,12 +45,16 @@ pub fn layout_list( if !tight { body += ParbreakElem::shared(); } + let body = body.set(ListElem::depth, Depth(1)); cells.push(Cell::new(Content::empty(), locator.next(&()))); - cells.push(Cell::new(marker.clone(), locator.next(&marker.span()))); + cells.push(Cell::new( + PdfMarkerTag::ListItemLabel(marker.clone()), + locator.next(&marker.span()), + )); cells.push(Cell::new(Content::empty(), locator.next(&()))); cells.push(Cell::new( - body.set(ListElem::depth, Depth(1)), + PdfMarkerTag::ListItemBody(body), locator.next(&item.body.span()), )); } @@ -131,11 +136,13 @@ pub fn layout_enum( body += ParbreakElem::shared(); } + let body = body.set(EnumElem::parents, smallvec![number]); + cells.push(Cell::new(Content::empty(), locator.next(&()))); - cells.push(Cell::new(resolved, locator.next(&()))); + cells.push(Cell::new(PdfMarkerTag::ListItemLabel(resolved), locator.next(&()))); cells.push(Cell::new(Content::empty(), locator.next(&()))); cells.push(Cell::new( - body.set(EnumElem::parents, smallvec![number]), + PdfMarkerTag::ListItemBody(body), locator.next(&item.body.span()), )); number = diff --git a/crates/typst-layout/src/pages/run.rs b/crates/typst-layout/src/pages/run.rs index c327adba2..a10d3c2a7 100644 --- a/crates/typst-layout/src/pages/run.rs +++ b/crates/typst-layout/src/pages/run.rs @@ -14,6 +14,7 @@ use typst_library::layout::{ VAlignment, }; use typst_library::model::Numbering; +use typst_library::pdf::ArtifactKind; use typst_library::routines::{Pair, Routines}; use typst_library::text::{LocalName, TextElem}; use typst_library::visualize::Paint; @@ -202,6 +203,11 @@ fn layout_page_run_impl( // Layout marginals. let mut layouted = Vec::with_capacity(fragment.len()); + + let header = header.as_ref().map(|h| h.clone().artifact(ArtifactKind::Header)); + let footer = footer.as_ref().map(|f| f.clone().artifact(ArtifactKind::Footer)); + let background = background.as_ref().map(|b| b.clone().artifact(ArtifactKind::Page)); + for inner in fragment { let header_size = Size::new(inner.width(), margin.top - header_ascent); let footer_size = Size::new(inner.width(), margin.bottom - footer_descent); @@ -212,9 +218,9 @@ fn layout_page_run_impl( fill: fill.clone(), numbering: numbering.clone(), supplement: supplement.clone(), - header: layout_marginal(header, header_size, Alignment::BOTTOM)?, - footer: layout_marginal(footer, footer_size, Alignment::TOP)?, - background: layout_marginal(background, full_size, mid)?, + header: layout_marginal(&header, header_size, Alignment::BOTTOM)?, + footer: layout_marginal(&footer, footer_size, Alignment::TOP)?, + background: layout_marginal(&background, full_size, mid)?, foreground: layout_marginal(foreground, full_size, mid)?, margin, binding, diff --git a/crates/typst-layout/src/rules.rs b/crates/typst-layout/src/rules.rs index a3616f96e..a1fa13d62 100644 --- a/crates/typst-layout/src/rules.rs +++ b/crates/typst-layout/src/rules.rs @@ -5,16 +5,15 @@ use ecow::{EcoVec, eco_format}; use smallvec::smallvec; use typst_library::diag::{At, SourceResult, bail}; use typst_library::foundations::{ - Content, Context, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, Smart, - StyleChain, Target, dict, + dict, Content, Context, LinkMarker, NativeElement, NativeRuleMap, Packed, Resolve, ShowFn, Smart, StyleChain, Target }; use typst_library::introspection::{Counter, Locator, LocatorLink}; use typst_library::layout::{ Abs, AlignElem, Alignment, Axes, BlockBody, BlockElem, ColumnsElem, Em, GridCell, GridChild, GridElem, GridItem, HAlignment, HElem, HideElem, InlineElem, LayoutElem, - Length, MoveElem, OuterVAlignment, PadElem, PlaceElem, PlacementScope, Region, Rel, - RepeatElem, RotateElem, ScaleElem, Sides, Size, Sizing, SkewElem, Spacing, - StackChild, StackElem, TrackSizings, VElem, + Length, MoveElem, OuterVAlignment, PadElem, PageElem, PlaceElem, PlacementScope, + Region, Rel, RepeatElem, RotateElem, ScaleElem, Sides, Size, Sizing, SkewElem, + Spacing, StackChild, StackElem, TrackSizings, VElem, }; use typst_library::math::EquationElem; use typst_library::model::{ @@ -23,12 +22,12 @@ use typst_library::model::{ LinkElem, ListElem, Outlinable, OutlineElem, OutlineEntry, ParElem, ParbreakElem, QuoteElem, RefElem, StrongElem, TableCell, TableElem, TermsElem, Works, }; -use typst_library::pdf::EmbedElem; +use typst_library::pdf::{ArtifactElem, EmbedElem, PdfMarkerTag}; use typst_library::text::{ DecoLine, Decoration, HighlightElem, ItalicToggle, LinebreakElem, LocalName, OverlineElem, RawElem, RawLine, ScriptKind, ShiftSettings, Smallcaps, SmallcapsElem, - SpaceElem, StrikeElem, SubElem, SuperElem, TextElem, TextSize, UnderlineElem, - WeightDelta, + SmartQuoteElem, SmartQuotes, SpaceElem, StrikeElem, SubElem, SuperElem, TextElem, + TextSize, UnderlineElem, WeightDelta, }; use typst_library::visualize::{ CircleElem, CurveElem, EllipseElem, ImageElem, LineElem, PathElem, PolygonElem, @@ -46,6 +45,7 @@ pub fn register(rules: &mut NativeRuleMap) { rules.register(Paged, LIST_RULE); rules.register(Paged, ENUM_RULE); rules.register(Paged, TERMS_RULE); + rules.register(Paged, LINK_MARKER_RULE); rules.register(Paged, LINK_RULE); rules.register(Paged, HEADING_RULE); rules.register(Paged, FIGURE_RULE); @@ -103,6 +103,8 @@ pub fn register(rules: &mut NativeRuleMap) { // PDF. rules.register(Paged, EMBED_RULE); + rules.register(Paged, PDF_ARTIFACT_RULE); + rules.register(Paged, PDF_MARKER_TAG_RULE); } const STRONG_RULE: ShowFn = |elem, _, styles| { @@ -172,9 +174,9 @@ const TERMS_RULE: ShowFn = |elem, _, styles| { for child in elem.children.iter() { let mut seq = vec![]; seq.extend(unpad.clone()); - seq.push(child.term.clone().strong()); + seq.push(PdfMarkerTag::ListItemLabel(child.term.clone().strong())); seq.push(separator.clone()); - seq.push(child.description.clone()); + seq.push(PdfMarkerTag::ListItemBody(child.description.clone())); // Text in wide term lists shall always turn into paragraphs. if !tight { @@ -210,10 +212,16 @@ const TERMS_RULE: ShowFn = |elem, _, styles| { Ok(realized) }; -const LINK_RULE: ShowFn = |elem, engine, _| { +const LINK_MARKER_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); + +const LINK_RULE: ShowFn = |elem, engine, styles| { let body = elem.body.clone(); let dest = elem.dest.resolve(engine.introspector).at(elem.span())?; - Ok(body.linked(dest)) + let alt = match elem.alt.get_cloned(styles) { + Some(alt) => Some(alt), + None => dest.alt_text(engine, styles)?, + }; + Ok(body.linked(dest, alt)) }; const HEADING_RULE: ShowFn = |elem, engine, styles| { @@ -254,7 +262,7 @@ const HEADING_RULE: ShowFn = |elem, engine, styles| { let spacing = HElem::new(SPACING_TO_NUMBERING.into()).with_weak(true).pack(); - realized = numbering + spacing + realized; + realized = PdfMarkerTag::Label(numbering) + spacing + realized; } let block = if indent != Abs::zero() { @@ -273,7 +281,7 @@ const HEADING_RULE: ShowFn = |elem, engine, styles| { const FIGURE_RULE: ShowFn = |elem, _, styles| { let span = elem.span(); - let mut realized = elem.body.clone(); + let mut realized = PdfMarkerTag::FigureBody(elem.body.clone()); // Build the caption, if any. if let Some(caption) = elem.caption.get_cloned(styles) { @@ -372,10 +380,11 @@ const FOOTNOTE_RULE: ShowFn = |elem, engine, styles| { let numbering = elem.numbering.get_ref(styles); let counter = Counter::of(FootnoteElem::ELEM); let num = counter.display_at_loc(engine, loc, styles, numbering)?; - let sup = SuperElem::new(num).pack().spanned(span); + let alt = FootnoteElem::alt_text(styles, &num.plain_text()); + let sup = PdfMarkerTag::Label(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))) + Ok(HElem::hole().pack() + sup.linked(Destination::Location(loc), Some(alt))) }; const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { @@ -392,10 +401,9 @@ const FOOTNOTE_ENTRY_RULE: ShowFn = |elem, engine, styles| { }; let num = counter.display_at_loc(engine, loc, styles, numbering)?; - let sup = SuperElem::new(num) - .pack() - .spanned(span) - .linked(Destination::Location(loc)) + let alt = num.plain_text(); + let sup = PdfMarkerTag::Label(SuperElem::new(num).pack().spanned(span)) + .linked(Destination::Location(loc), Some(alt)) .located(loc.variant(1)); Ok(Content::sequence([ @@ -426,6 +434,7 @@ const OUTLINE_RULE: ShowFn = |elem, engine, styles| { let depth = elem.depth.get(styles).unwrap_or(NonZeroUsize::MAX); // Build the outline entries. + let mut entries = vec![]; for elem in elems { let Some(outlinable) = elem.with::() else { bail!(span, "cannot outline {}", elem.func().name()); @@ -434,10 +443,13 @@ const OUTLINE_RULE: ShowFn = |elem, engine, styles| { let level = outlinable.level(); if outlinable.outlined() && level <= depth { let entry = OutlineEntry::new(level, elem); - seq.push(entry.pack().spanned(span)); + entries.push(entry.pack().spanned(span)); } } + // Wrap the entries into a marker for pdf tagging. + seq.push(PdfMarkerTag::OutlineBody(Content::sequence(entries))); + Ok(Content::sequence(seq)) }; @@ -447,7 +459,24 @@ const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| { let context = context.track(); let prefix = elem.prefix(engine, context, span)?; - let inner = elem.inner(engine, context, span)?; + let body = elem.body().at(span)?; + let page = elem.page(engine, context, span)?; + let alt = { + 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(); + let quotes = SmartQuotes::get( + styles.get_ref(SmartQuoteElem::quotes), + styles.get(TextElem::lang), + styles.get(TextElem::region), + styles.get(SmartQuoteElem::alternative), + ); + let open = quotes.double_open; + let close = quotes.double_close; + eco_format!("{prefix} {open}{body}{close} {page_str} {page_nr}",) + }; + let inner = elem.build_inner(context, span, body, page)?; let block = if elem.element.is::() { let body = prefix.unwrap_or_default() + inner; BlockElem::new() @@ -459,7 +488,7 @@ const OUTLINE_ENTRY_RULE: ShowFn = |elem, engine, styles| { }; let loc = elem.element_location().at(span)?; - Ok(block.linked(Destination::Location(loc))) + Ok(block.linked(Destination::Location(loc), Some(alt))) }; const REF_RULE: ShowFn = |elem, engine, styles| elem.realize(engine, styles); @@ -507,25 +536,29 @@ const BIBLIOGRAPHY_RULE: ShowFn = |elem, engine, styles| { let mut cells = vec![]; for (prefix, reference) in references { + let prefix = PdfMarkerTag::ListItemLabel(prefix.clone().unwrap_or_default()); cells.push(GridChild::Item(GridItem::Cell( - Packed::new(GridCell::new(prefix.clone().unwrap_or_default())) - .spanned(span), + Packed::new(GridCell::new(prefix)).spanned(span), ))); + + let reference = PdfMarkerTag::BibEntry(reference.clone()); cells.push(GridChild::Item(GridItem::Cell( - Packed::new(GridCell::new(reference.clone())).spanned(span), + Packed::new(GridCell::new(reference)).spanned(span), ))); } - seq.push( - GridElem::new(cells) - .with_columns(TrackSizings(smallvec![Sizing::Auto; 2])) - .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()])) - .with_row_gutter(TrackSizings(smallvec![row_gutter.into()])) - .pack() - .spanned(span), - ); + + let grid = GridElem::new(cells) + .with_columns(TrackSizings(smallvec![Sizing::Auto; 2])) + .with_column_gutter(TrackSizings(smallvec![COLUMN_GUTTER.into()])) + .with_row_gutter(TrackSizings(smallvec![row_gutter.into()])) + .pack() + .spanned(span); + // TODO(accessibility): infer list numbering from style? + seq.push(PdfMarkerTag::Bibliography(true, grid)); } else { + let mut body = vec![]; for (_, reference) in references { - let realized = reference.clone(); + let realized = PdfMarkerTag::BibEntry(reference.clone()); let block = if works.hanging_indent { let body = HElem::new((-INDENT).into()).pack() + realized; let inset = Sides::default() @@ -537,8 +570,9 @@ const BIBLIOGRAPHY_RULE: ShowFn = |elem, engine, styles| { BlockElem::new().with_body(Some(BlockBody::Content(realized))) }; - seq.push(block.pack().spanned(span)); + body.push(block.pack().spanned(span)); } + seq.push(PdfMarkerTag::Bibliography(false, Content::sequence(body))); } Ok(Content::sequence(seq)) @@ -840,3 +874,7 @@ const EQUATION_RULE: ShowFn = |elem, _, styles| { }; const EMBED_RULE: ShowFn = |_, _, _| Ok(Content::empty()); + +const PDF_ARTIFACT_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); + +const PDF_MARKER_TAG_RULE: ShowFn = |elem, _, _| Ok(elem.body.clone()); diff --git a/crates/typst-library/src/foundations/content/mod.rs b/crates/typst-library/src/foundations/content/mod.rs index ca5c7b6d8..5d469774f 100644 --- a/crates/typst-library/src/foundations/content/mod.rs +++ b/crates/typst-library/src/foundations/content/mod.rs @@ -23,15 +23,16 @@ use serde::{Serialize, Serializer}; use typst_syntax::Span; use typst_utils::singleton; -use crate::diag::{SourceResult, StrResult}; +use crate::diag::{SourceResult, StrResult, bail}; use crate::engine::Engine; use crate::foundations::{ - Context, Dict, IntoValue, Label, Property, Recipe, RecipeIndex, Repr, Selector, Str, - Style, StyleChain, Styles, Value, func, repr, scope, ty, + Args, Context, Dict, IntoValue, Label, Property, Recipe, RecipeIndex, Repr, Selector, + Str, Style, StyleChain, Styles, Value, func, repr, scope, ty, }; -use crate::introspection::Location; +use crate::introspection::{Locatable, Location}; use crate::layout::{AlignElem, Alignment, Axes, Length, MoveElem, PadElem, Rel, Sides}; use crate::model::{Destination, EmphElem, LinkElem, StrongElem}; +use crate::pdf::{ArtifactElem, ArtifactKind}; use crate::text::UnderlineElem; /// A piece of document content. @@ -476,8 +477,12 @@ impl Content { } /// Link the content somewhere. - pub fn linked(self, dest: Destination) -> Self { - self.set(LinkElem::current, Some(dest)) + pub fn linked(self, dest: Destination, alt: Option) -> Self { + let span = self.span(); + LinkMarker::new(self, dest.clone(), alt, span) + .pack() + .spanned(span) + .set(LinkElem::current, Some(dest)) } /// Set alignments for this content. @@ -506,6 +511,12 @@ impl Content { .pack() .spanned(span) } + + /// Link the content somewhere. + pub fn artifact(self, kind: ArtifactKind) -> Self { + let span = self.span(); + ArtifactElem::new(self).with_kind(kind).pack().spanned(span) + } } #[scope] @@ -773,6 +784,30 @@ impl Repr for StyledElem { } } +/// An element that associates the body of a link with the destination. +#[elem(Locatable, Construct)] +pub struct LinkMarker { + /// The content. + #[internal] + #[required] + pub body: Content, + #[internal] + #[required] + pub dest: Destination, + #[internal] + #[required] + pub alt: Option, + #[internal] + #[required] + pub span: Span, +} + +impl Construct for LinkMarker { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually"); + } +} + impl IntoValue for T { fn into_value(self) -> Value { Value::Content(self.pack()) diff --git a/crates/typst-library/src/layout/grid/mod.rs b/crates/typst-library/src/layout/grid/mod.rs index de540a578..da896d700 100644 --- a/crates/typst-library/src/layout/grid/mod.rs +++ b/crates/typst-library/src/layout/grid/mod.rs @@ -13,6 +13,7 @@ use crate::foundations::{ Array, CastInfo, Content, Context, Fold, FromValue, Func, IntoValue, Packed, Reflect, Resolve, Smart, StyleChain, Value, cast, elem, scope, }; +use crate::introspection::Locatable; use crate::layout::{ Alignment, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, Sizing, }; @@ -136,7 +137,7 @@ use crate::visualize::{Paint, Stroke}; /// /// Furthermore, strokes of a repeated grid header or footer will take /// precedence over regular cell strokes. -#[elem(scope)] +#[elem(scope, Locatable)] pub struct GridElem { /// The column sizes. /// @@ -640,7 +641,7 @@ pub struct GridVLine { /// which allows you, for example, to apply styles based on a cell's position. /// Refer to the examples of the [`table.cell`]($table.cell) element to learn /// more about this. -#[elem(name = "cell", title = "Grid Cell")] +#[elem(name = "cell", title = "Grid Cell", Locatable)] pub struct GridCell { /// The cell's body. #[required] diff --git a/crates/typst-library/src/layout/grid/resolve.rs b/crates/typst-library/src/layout/grid/resolve.rs index b7d2ffa6c..d4f519cf9 100644 --- a/crates/typst-library/src/layout/grid/resolve.rs +++ b/crates/typst-library/src/layout/grid/resolve.rs @@ -22,6 +22,7 @@ use typst_syntax::Span; use typst_utils::NonZeroExt; use crate::introspection::SplitLocator; +use crate::pdf::{TableCellKind, TableHeaderScope}; /// Convert a grid to a cell grid. #[typst_macros::time(span = elem.span())] @@ -217,6 +218,7 @@ impl ResolvableCell for Packed { breakable: bool, locator: Locator<'a>, styles: StyleChain, + kind: Smart, ) -> Cell<'a> { let cell = &mut *self; let colspan = cell.colspan.get(styles); @@ -224,6 +226,8 @@ impl ResolvableCell for Packed { let breakable = cell.breakable.get(styles).unwrap_or(breakable); let fill = cell.fill.get_cloned(styles).unwrap_or_else(|| fill.clone()); + let kind = cell.kind.get(styles).or(kind); + let cell_stroke = cell.stroke.resolve(styles); let stroke_overridden = cell_stroke.as_ref().map(|side| matches!(side, Some(Some(_)))); @@ -267,6 +271,7 @@ impl ResolvableCell for Packed { }), ); cell.breakable.set(Smart::Custom(breakable)); + cell.kind.set(kind); Cell { body: self.pack(), locator, @@ -312,6 +317,7 @@ impl ResolvableCell for Packed { breakable: bool, locator: Locator<'a>, styles: StyleChain, + _: Smart, ) -> Cell<'a> { let cell = &mut *self; let colspan = cell.colspan.get(styles); @@ -518,6 +524,7 @@ pub trait ResolvableCell { breakable: bool, locator: Locator<'a>, styles: StyleChain, + kind: Smart, ) -> Cell<'a>; /// Returns this cell's column override. @@ -1194,8 +1201,14 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // a non-empty row. let mut first_available_row = 0; + // The cell kind is currently only used for tagged PDF. + let cell_kind; + let (header_footer_items, simple_item) = match child { - ResolvableGridChild::Header { repeat, level, span, items, .. } => { + ResolvableGridChild::Header { repeat, level, span, items } => { + cell_kind = + Smart::Custom(TableCellKind::Header(level, TableHeaderScope::Column)); + row_group_data = Some(RowGroupData { range: None, span, @@ -1222,11 +1235,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> { (Some(items), None) } - ResolvableGridChild::Footer { repeat, span, items, .. } => { + ResolvableGridChild::Footer { repeat, span, items } => { if footer.is_some() { bail!(span, "cannot have more than one footer"); } + cell_kind = Smart::Custom(TableCellKind::Footer); + row_group_data = Some(RowGroupData { range: None, span, @@ -1245,6 +1260,8 @@ impl<'x> CellGridResolver<'_, '_, 'x> { (Some(items), None) } ResolvableGridChild::Item(item) => { + cell_kind = Smart::Custom(TableCellKind::Data); + if matches!(item, ResolvableGridItem::Cell(_)) { *at_least_one_cell = true; } @@ -1435,7 +1452,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // Let's resolve the cell so it can determine its own fields // based on its final position. - let cell = self.resolve_cell(cell, x, y, rowspan, cell_span)?; + let cell = self.resolve_cell(cell, x, y, rowspan, cell_span, cell_kind)?; if largest_index >= resolved_cells.len() { // Ensure the length of the vector of resolved cells is @@ -1530,6 +1547,13 @@ impl<'x> CellGridResolver<'_, '_, 'x> { // and footers without having to loop through them each time. // Cells themselves, unfortunately, still have to. assert!(resolved_cells[*local_auto_index].is_none()); + let kind = match row_group.kind { + RowGroupKind::Header => TableCellKind::Header( + NonZeroU32::ONE, + TableHeaderScope::default(), + ), + RowGroupKind::Footer => TableCellKind::Footer, + }; resolved_cells[*local_auto_index] = Some(Entry::Cell(self.resolve_cell( T::default(), @@ -1537,6 +1561,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { first_available_row, 1, Span::detached(), + Smart::Custom(kind), )?)); group_start..group_end @@ -1661,6 +1686,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { y, 1, Span::detached(), + Smart::Auto, )?)) } }) @@ -1906,6 +1932,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { y: usize, rowspan: usize, cell_span: Span, + kind: Smart, ) -> SourceResult> where T: ResolvableCell + Default, @@ -1942,6 +1969,7 @@ impl<'x> CellGridResolver<'_, '_, 'x> { breakable, self.locator.next(&cell_span), self.styles, + kind, )) } } diff --git a/crates/typst-library/src/layout/hide.rs b/crates/typst-library/src/layout/hide.rs index 7e096eaa9..5ef30461d 100644 --- a/crates/typst-library/src/layout/hide.rs +++ b/crates/typst-library/src/layout/hide.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, elem}; +use crate::introspection::Locatable; /// Hides content without affecting layout. /// @@ -12,7 +13,7 @@ use crate::foundations::{Content, elem}; /// Hello Jane \ /// #hide[Hello] Joe /// ``` -#[elem] +#[elem(Locatable)] pub struct HideElem { /// The content to hide. #[required] diff --git a/crates/typst-library/src/layout/repeat.rs b/crates/typst-library/src/layout/repeat.rs index ef7a5bb05..c6845aba7 100644 --- a/crates/typst-library/src/layout/repeat.rs +++ b/crates/typst-library/src/layout/repeat.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, elem}; +use crate::introspection::Locatable; use crate::layout::Length; /// Repeats content to the available space. @@ -22,7 +23,7 @@ use crate::layout::Length; /// Berlin, the 22nd of December, 2022 /// ] /// ``` -#[elem] +#[elem(Locatable)] pub struct RepeatElem { /// The content to repeat. #[required] diff --git a/crates/typst-library/src/math/equation.rs b/crates/typst-library/src/math/equation.rs index 45831f52c..a9ecf2de3 100644 --- a/crates/typst-library/src/math/equation.rs +++ b/crates/typst-library/src/math/equation.rs @@ -1,6 +1,7 @@ use std::num::NonZeroUsize; use codex::styling::MathVariant; +use ecow::EcoString; use typst_utils::NonZeroExt; use unicode_math_class::MathClass; @@ -47,6 +48,9 @@ use crate::text::{FontFamily, FontList, FontWeight, LocalName, TextElem}; /// [main math page]($category/math). #[elem(Locatable, Synthesize, ShowSet, Count, LocalName, Refable, Outlinable)] pub struct EquationElem { + /// An alternative description of the mathematical equation. + pub alt: Option, + /// Whether the equation is displayed as a separate block. #[default(false)] pub block: bool, diff --git a/crates/typst-library/src/model/bibliography.rs b/crates/typst-library/src/model/bibliography.rs index 23e89f86b..bce376ed7 100644 --- a/crates/typst-library/src/model/bibliography.rs +++ b/crates/typst-library/src/model/bibliography.rs @@ -795,7 +795,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); + let alt = content.plain_text(); + content = content.linked(dest, Some(alt)); } StrResult::Ok(content) }) @@ -930,8 +931,9 @@ impl ElemRenderer<'_> { if let Some(hayagriva::ElemMeta::Entry(i)) = elem.meta && let Some(location) = (self.link)(i) { + let alt = content.plain_text(); let dest = Destination::Location(location); - content = content.linked(dest); + content = content.linked(dest, Some(alt)); } Ok(content) diff --git a/crates/typst-library/src/model/cite.rs b/crates/typst-library/src/model/cite.rs index f30e7a9c4..e80cec385 100644 --- a/crates/typst-library/src/model/cite.rs +++ b/crates/typst-library/src/model/cite.rs @@ -42,7 +42,7 @@ use crate::text::{Lang, Region, TextElem}; /// This function indirectly has dedicated syntax. [References]($ref) can be /// used to cite works from the bibliography. The label then corresponds to the /// citation key. -#[elem(Synthesize)] +#[elem(Locatable, Synthesize)] pub struct CiteElem { /// The citation key that identifies the entry in the bibliography that /// shall be cited, as a label. diff --git a/crates/typst-library/src/model/emph.rs b/crates/typst-library/src/model/emph.rs index b9eb9ebdd..55b69aee2 100644 --- a/crates/typst-library/src/model/emph.rs +++ b/crates/typst-library/src/model/emph.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, elem}; +use crate::introspection::Locatable; /// Emphasizes content by toggling italics. /// @@ -23,7 +24,7 @@ use crate::foundations::{Content, elem}; /// This function also has dedicated syntax: To emphasize content, simply /// enclose it in underscores (`_`). Note that this only works at word /// boundaries. To emphasize part of a word, you have to use the function. -#[elem(title = "Emphasis", keywords = ["italic"])] +#[elem(title = "Emphasis", keywords = ["italic"], Locatable)] pub struct EmphElem { /// The content to emphasize. #[required] diff --git a/crates/typst-library/src/model/enum.rs b/crates/typst-library/src/model/enum.rs index adb738879..194453a41 100644 --- a/crates/typst-library/src/model/enum.rs +++ b/crates/typst-library/src/model/enum.rs @@ -4,6 +4,7 @@ use smallvec::SmallVec; use crate::diag::bail; use crate::foundations::{Array, Content, Packed, Smart, Styles, cast, elem, scope}; +use crate::introspection::Locatable; use crate::layout::{Alignment, Em, HAlignment, Length, VAlignment}; use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern}; @@ -63,7 +64,7 @@ use crate::model::{ListItemLike, ListLike, Numbering, NumberingPattern}; /// Enumeration items can contain multiple paragraphs and other block-level /// content. All content that is indented more than an item's marker becomes /// part of that item. -#[elem(scope, title = "Numbered List")] +#[elem(scope, title = "Numbered List", Locatable)] pub struct EnumElem { /// Defines the default [spacing]($enum.spacing) of the enumeration. If it /// is `{false}`, the items are spaced apart with @@ -216,7 +217,7 @@ impl EnumElem { } /// An enumeration item. -#[elem(name = "item", title = "Numbered List Item")] +#[elem(name = "item", title = "Numbered List Item", Locatable)] pub struct EnumItem { /// The item's number. #[positional] diff --git a/crates/typst-library/src/model/figure.rs b/crates/typst-library/src/model/figure.rs index daab37e44..f4b45f897 100644 --- a/crates/typst-library/src/model/figure.rs +++ b/crates/typst-library/src/model/figure.rs @@ -409,7 +409,7 @@ impl Outlinable for Packed { /// caption: [A rectangle], /// ) /// ``` -#[elem(name = "caption", Synthesize)] +#[elem(name = "caption", Locatable, Synthesize)] pub struct FigureCaption { /// The caption's position in the figure. Either `{top}` or `{bottom}`. /// diff --git a/crates/typst-library/src/model/footnote.rs b/crates/typst-library/src/model/footnote.rs index 51b9f25fd..be8f8ec42 100644 --- a/crates/typst-library/src/model/footnote.rs +++ b/crates/typst-library/src/model/footnote.rs @@ -1,6 +1,7 @@ use std::num::NonZeroUsize; use std::str::FromStr; +use ecow::{EcoString, eco_format}; use typst_utils::NonZeroExt; use crate::diag::{StrResult, bail}; @@ -12,7 +13,7 @@ use crate::foundations::{ use crate::introspection::{Count, CounterUpdate, Locatable, Location}; use crate::layout::{Abs, Em, Length, Ratio}; use crate::model::{Numbering, NumberingPattern, ParElem}; -use crate::text::{TextElem, TextSize}; +use crate::text::{LocalName, TextElem, TextSize}; use crate::visualize::{LineElem, Stroke}; /// A footnote. @@ -82,7 +83,16 @@ impl FootnoteElem { type FootnoteEntry; } +impl LocalName for Packed { + const KEY: &'static str = "footnote"; +} + impl FootnoteElem { + pub fn alt_text(styles: StyleChain, num: &str) -> EcoString { + let local_name = Packed::::local_name_in(styles); + eco_format!("{local_name} {num}") + } + /// Creates a new footnote that the passed content as its body. pub fn with_content(content: Content) -> Self { Self::new(FootnoteBody::Content(content)) @@ -176,7 +186,7 @@ cast! { /// page run is a sequence of pages without an explicit pagebreak in between). /// For this reason, set and show rules for footnote entries should be defined /// before any page content, typically at the very start of the document. -#[elem(name = "entry", title = "Footnote Entry", ShowSet)] +#[elem(name = "entry", title = "Footnote Entry", Locatable, ShowSet)] pub struct FootnoteEntry { /// The footnote for this entry. Its location can be used to determine /// the footnote counter state. diff --git a/crates/typst-library/src/model/link.rs b/crates/typst-library/src/model/link.rs index 16e1cc5a9..ad9fd124c 100644 --- a/crates/typst-library/src/model/link.rs +++ b/crates/typst-library/src/model/link.rs @@ -1,15 +1,18 @@ use std::ops::Deref; +use std::str::FromStr; use comemo::Tracked; use ecow::{EcoString, eco_format}; -use crate::diag::{StrResult, bail}; +use crate::diag::{SourceResult, StrResult, bail}; +use crate::engine::Engine; use crate::foundations::{ Content, Label, Packed, Repr, ShowSet, Smart, StyleChain, Styles, cast, elem, }; -use crate::introspection::{Introspector, Locatable, Location}; -use crate::layout::Position; -use crate::text::TextElem; +use crate::introspection::{Counter, CounterKey, Introspector, Locatable, Location}; +use crate::layout::{PageElem, Position}; +use crate::model::NumberingPattern; +use crate::text::{LocalName, TextElem}; /// Links to a URL or a location in the document. /// @@ -85,6 +88,9 @@ use crate::text::TextElem; /// generated. #[elem(Locatable)] pub struct LinkElem { + /// An alternative description of 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 @@ -212,7 +218,29 @@ pub enum Destination { Location(Location), } -impl Destination {} +impl Destination { + pub fn alt_text( + &self, + engine: &mut Engine, + styles: StyleChain, + ) -> SourceResult> { + let alt = match self { + Destination::Url(url) => Some(url.clone().into_inner()), + Destination::Position(_) => None, + &Destination::Location(loc) => { + let numbering = loc + .page_numbering(engine) + .unwrap_or_else(|| NumberingPattern::from_str("1").unwrap().into()); + let content = Counter::new(CounterKey::Page) + .display_at_loc(engine, loc, styles, &numbering)?; + let page_nr = content.plain_text(); + let page_str = PageElem::local_name_in(styles); + Some(eco_format!("{page_str} {page_nr}")) + } + }; + Ok(alt) + } +} impl Repr for Destination { fn repr(&self) -> EcoString { diff --git a/crates/typst-library/src/model/list.rs b/crates/typst-library/src/model/list.rs index 5df37ad33..5b81ce8fe 100644 --- a/crates/typst-library/src/model/list.rs +++ b/crates/typst-library/src/model/list.rs @@ -6,6 +6,7 @@ use crate::foundations::{ Array, Content, Context, Depth, Func, NativeElement, Packed, Smart, StyleChain, Styles, Value, cast, elem, scope, }; +use crate::introspection::Locatable; use crate::layout::{Em, Length}; use crate::text::TextElem; @@ -40,7 +41,7 @@ use crate::text::TextElem; /// followed by a space to create a list item. A list item can contain multiple /// paragraphs and other block-level content. All content that is indented /// more than an item's marker becomes part of that item. -#[elem(scope, title = "Bullet List")] +#[elem(scope, title = "Bullet List", Locatable)] pub struct ListElem { /// Defines the default [spacing]($list.spacing) of the list. If it is /// `{false}`, the items are spaced apart with @@ -135,7 +136,7 @@ impl ListElem { } /// A bullet list item. -#[elem(name = "item", title = "Bullet List Item")] +#[elem(name = "item", title = "Bullet List Item", Locatable)] pub struct ListItem { /// The item's body. #[required] diff --git a/crates/typst-library/src/model/outline.rs b/crates/typst-library/src/model/outline.rs index 96c4eb2b4..8e1936b0a 100644 --- a/crates/typst-library/src/model/outline.rs +++ b/crates/typst-library/src/model/outline.rs @@ -20,6 +20,7 @@ use crate::layout::{ RepeatElem, Sides, }; use crate::model::{HeadingElem, NumberingPattern, ParElem, Refable}; +use crate::pdf::PdfMarkerTag; use crate::text::{LocalName, SpaceElem, TextElem}; /// A table of contents, figures, or other elements. @@ -323,7 +324,7 @@ pub trait Outlinable: Refable { /// With show-set and show rules on outline entries, you can richly customize /// the outline's appearance. See the /// [section on styling the outline]($outline/#styling-the-outline) for details. -#[elem(scope, name = "entry", title = "Outline Entry")] +#[elem(scope, name = "entry", title = "Outline Entry", Locatable)] pub struct OutlineEntry { /// The nesting level of this outline entry. Starts at `{1}` for top-level /// entries. @@ -492,7 +493,7 @@ impl OutlineEntry { let styles = context.styles().at(span)?; let numbers = outlinable.counter().display_at_loc(engine, loc, styles, numbering)?; - Ok(Some(outlinable.prefix(numbers))) + Ok(Some(PdfMarkerTag::Label(outlinable.prefix(numbers)))) } /// Creates the default inner content of the entry. @@ -505,53 +506,9 @@ impl OutlineEntry { context: Tracked, span: Span, ) -> SourceResult { - let styles = context.styles().at(span)?; - - let mut seq = vec![]; - - // Isolate the entry body in RTL because the page number is typically - // LTR. I'm not sure whether LTR should conceptually also be isolated, - // but in any case we don't do it for now because the text shaping - // pipeline does tend to choke a bit on default ignorables (in - // particular the CJK-Latin spacing). - // - // See also: - // - https://github.com/typst/typst/issues/4476 - // - https://github.com/typst/typst/issues/5176 - let rtl = styles.resolve(TextElem::dir) == Dir::RTL; - if rtl { - // "Right-to-Left Embedding" - seq.push(TextElem::packed("\u{202B}")); - } - - seq.push(self.body().at(span)?); - - if rtl { - // "Pop Directional Formatting" - seq.push(TextElem::packed("\u{202C}")); - } - - // Add the filler between the section name and page number. - if let Some(filler) = self.fill.get_cloned(styles) { - seq.push(SpaceElem::shared().clone()); - seq.push( - BoxElem::new() - .with_body(Some(filler)) - .with_width(Fr::one().into()) - .pack() - .spanned(span), - ); - seq.push(SpaceElem::shared().clone()); - } else { - seq.push(HElem::new(Fr::one().into()).pack().spanned(span)); - } - - // 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)?); - - Ok(Content::sequence(seq)) + let body = self.body().at(span)?; + let page = self.page(engine, context, span)?; + self.build_inner(context, span, body, page) } /// The content which is displayed in place of the referred element at its @@ -584,6 +541,62 @@ impl OutlineEntry { } impl OutlineEntry { + pub fn build_inner( + &self, + context: Tracked, + span: Span, + body: Content, + page: Content, + ) -> SourceResult { + let styles = context.styles().at(span)?; + + let mut seq = vec![]; + + // Isolate the entry body in RTL because the page number is typically + // LTR. I'm not sure whether LTR should conceptually also be isolated, + // but in any case we don't do it for now because the text shaping + // pipeline does tend to choke a bit on default ignorables (in + // particular the CJK-Latin spacing). + // + // See also: + // - https://github.com/typst/typst/issues/4476 + // - https://github.com/typst/typst/issues/5176 + let rtl = styles.resolve(TextElem::dir) == Dir::RTL; + if rtl { + // "Right-to-Left Embedding" + seq.push(TextElem::packed("\u{202B}")); + } + + seq.push(body); + + if rtl { + // "Pop Directional Formatting" + seq.push(TextElem::packed("\u{202C}")); + } + + // Add the filler between the section name and page number. + if let Some(filler) = self.fill.get_cloned(styles) { + seq.push(SpaceElem::shared().clone()); + seq.push( + BoxElem::new() + .with_body(Some(filler)) + .with_width(Fr::one().into()) + .pack() + .spanned(span), + ); + seq.push(SpaceElem::shared().clone()); + } else { + seq.push(HElem::new(Fr::one().into()).pack().spanned(span)); + } + + // 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(page); + + Ok(Content::sequence(seq)) + } + fn outlinable(&self) -> StrResult<&dyn Outlinable> { self.element .with::() diff --git a/crates/typst-library/src/model/par.rs b/crates/typst-library/src/model/par.rs index 372e63e00..f32520a9f 100644 --- a/crates/typst-library/src/model/par.rs +++ b/crates/typst-library/src/model/par.rs @@ -93,7 +93,7 @@ use crate::model::Numbering; /// let $a$ be the smallest of the /// three integers. Then, we ... /// ``` -#[elem(scope, title = "Paragraph")] +#[elem(scope, title = "Paragraph", Locatable)] pub struct ParElem { /// The spacing between lines. /// diff --git a/crates/typst-library/src/model/reference.rs b/crates/typst-library/src/model/reference.rs index ff695182f..2c6261627 100644 --- a/crates/typst-library/src/model/reference.rs +++ b/crates/typst-library/src/model/reference.rs @@ -342,6 +342,12 @@ fn realize_reference( Smart::Custom(Some(supplement)) => supplement.resolve(engine, styles, [elem])?, }; + let alt = { + let supplement = supplement.plain_text(); + let numbering = numbers.plain_text(); + eco_format!("{supplement} {numbering}",) + }; + let mut content = numbers; if !supplement.is_empty() { content = supplement + TextElem::packed("\u{a0}") + content; @@ -353,7 +359,7 @@ fn realize_reference( // TODO: We should probably also use `LinkElem` in the paged target, but // it's a bit breaking and it becomes hard to style links without // affecting references, so this change should be well-considered. - content.linked(Destination::Location(loc)) + content.linked(Destination::Location(loc), Some(alt)) }) } diff --git a/crates/typst-library/src/model/strong.rs b/crates/typst-library/src/model/strong.rs index a1cfb36ab..49c7def95 100644 --- a/crates/typst-library/src/model/strong.rs +++ b/crates/typst-library/src/model/strong.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, elem}; +use crate::introspection::Locatable; /// Strongly emphasizes content by increasing the font weight. /// @@ -18,7 +19,7 @@ use crate::foundations::{Content, elem}; /// simply enclose it in stars/asterisks (`*`). Note that this only works at /// word boundaries. To strongly emphasize part of a word, you have to use the /// function. -#[elem(title = "Strong Emphasis", keywords = ["bold", "weight"])] +#[elem(title = "Strong Emphasis", keywords = ["bold", "weight"], Locatable)] pub struct StrongElem { /// The delta to apply on the font weight. /// diff --git a/crates/typst-library/src/model/table.rs b/crates/typst-library/src/model/table.rs index 412ca4229..d57f47d94 100644 --- a/crates/typst-library/src/model/table.rs +++ b/crates/typst-library/src/model/table.rs @@ -1,15 +1,18 @@ use std::num::{NonZeroU32, NonZeroUsize}; use std::sync::Arc; +use ecow::EcoString; use typst_utils::NonZeroExt; use crate::diag::{HintedStrResult, HintedString, bail}; use crate::foundations::{Content, Packed, Smart, cast, elem, scope}; +use crate::introspection::Locatable; use crate::layout::{ Abs, Alignment, Celled, GridCell, GridFooter, GridHLine, GridHeader, GridVLine, Length, OuterHAlignment, OuterVAlignment, Rel, Sides, TrackSizings, }; use crate::model::Figurable; +use crate::pdf::TableCellKind; use crate::text::LocalName; use crate::visualize::{Paint, Stroke}; @@ -113,7 +116,7 @@ use crate::visualize::{Paint, Stroke}; /// [Robert], b, a, b, /// ) /// ``` -#[elem(scope, LocalName, Figurable)] +#[elem(scope, Locatable, LocalName, Figurable)] pub struct TableElem { /// The column sizes. See the [grid documentation]($grid) for more /// information on track sizing. @@ -222,6 +225,9 @@ pub struct TableElem { #[default(Celled::Value(Sides::splat(Some(Abs::pt(5.0).into()))))] pub inset: Celled>>>, + // TODO: docs + pub summary: Option, + /// The contents of the table cells, plus any extra table lines specified /// with the [`table.hline`]($table.hline) and /// [`table.vline`]($table.vline) elements. @@ -646,7 +652,7 @@ pub struct TableVLine { /// [Vikram], [49], [Perseverance], /// ) /// ``` -#[elem(name = "cell", title = "Table Cell")] +#[elem(name = "cell", title = "Table Cell", Locatable)] pub struct TableCell { /// The cell's body. #[required] @@ -681,6 +687,10 @@ pub struct TableCell { #[fold] pub stroke: Sides>>>, + #[internal] + #[parse(Some(Smart::Auto))] + pub kind: Smart, + /// Whether rows spanned by this cell can be placed in different pages. /// When equal to `{auto}`, a cell spanning only fixed-size rows is /// unbreakable, while a cell spanning at least one `{auto}`-sized row is diff --git a/crates/typst-library/src/model/terms.rs b/crates/typst-library/src/model/terms.rs index 47939b9b4..4a03e6bfe 100644 --- a/crates/typst-library/src/model/terms.rs +++ b/crates/typst-library/src/model/terms.rs @@ -2,6 +2,7 @@ use crate::diag::bail; use crate::foundations::{ Array, Content, NativeElement, Packed, Smart, Styles, cast, elem, scope, }; +use crate::introspection::Locatable; use crate::layout::{Em, HElem, Length}; use crate::model::{ListItemLike, ListLike}; @@ -21,7 +22,7 @@ use crate::model::{ListItemLike, ListLike}; /// # Syntax /// This function also has dedicated syntax: Starting a line with a slash, /// followed by a term, a colon and a description creates a term list item. -#[elem(scope, title = "Term List")] +#[elem(scope, title = "Term List", Locatable)] pub struct TermsElem { /// Defines the default [spacing]($terms.spacing) of the term list. If it is /// `{false}`, the items are spaced apart with @@ -112,7 +113,7 @@ impl TermsElem { } /// A term list item. -#[elem(name = "item", title = "Term List Item")] +#[elem(name = "item", title = "Term List Item", Locatable)] pub struct TermItem { /// The term described by the list item. #[required] diff --git a/crates/typst-library/src/pdf/accessibility.rs b/crates/typst-library/src/pdf/accessibility.rs new file mode 100644 index 000000000..53ae4d4f0 --- /dev/null +++ b/crates/typst-library/src/pdf/accessibility.rs @@ -0,0 +1,161 @@ +use std::num::NonZeroU32; + +use typst_macros::{Cast, elem, func}; +use typst_utils::NonZeroExt; + +use crate::diag::SourceResult; +use crate::diag::bail; +use crate::engine::Engine; +use crate::foundations::{Args, Construct, Content, NativeElement, Smart}; +use crate::introspection::Locatable; +use crate::model::TableCell; + +/// Mark content as a PDF artifact. +/// TODO: maybe generalize this and use it to mark html elements with `aria-hidden="true"`? +#[elem(Locatable)] +pub struct ArtifactElem { + /// The artifact kind. + #[default(ArtifactKind::Other)] + pub kind: ArtifactKind, + + /// The content that is an artifact. + #[required] + pub body: Content, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Cast)] +pub enum ArtifactKind { + /// Page header artifacts. + Header, + /// Page footer artifacts. + Footer, + /// Other page artifacts. + Page, + /// Other artifacts. + #[default] + Other, +} + +// TODO: feature gate +/// Explicity define this cell as a header cell. +#[func] +pub fn header_cell( + #[named] + #[default(NonZeroU32::ONE)] + level: NonZeroU32, + #[named] + #[default] + scope: TableHeaderScope, + /// The table cell. + cell: TableCell, +) -> Content { + cell.with_kind(Smart::Custom(TableCellKind::Header(level, scope))) + .pack() +} + +// TODO: feature gate +/// Explicity define this cell as a data cell. +#[func] +pub fn data_cell( + /// The table cell. + cell: TableCell, +) -> Content { + cell.with_kind(Smart::Custom(TableCellKind::Data)).pack() +} + +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash)] +pub enum TableCellKind { + Header(NonZeroU32, TableHeaderScope), + Footer, + #[default] + Data, +} + +/// The scope of a table header cell. +#[derive(Debug, Default, Copy, Clone, Eq, PartialEq, Hash, Cast)] +pub enum TableHeaderScope { + /// The header cell refers to both the row and the column. + Both, + /// The header cell refers to the column. + #[default] + Column, + /// The header cell refers to the row. + Row, +} + +impl TableHeaderScope { + pub fn refers_to_column(&self) -> bool { + match self { + TableHeaderScope::Both => true, + TableHeaderScope::Column => true, + TableHeaderScope::Row => false, + } + } + + pub fn refers_to_row(&self) -> bool { + match self { + TableHeaderScope::Both => true, + TableHeaderScope::Column => false, + TableHeaderScope::Row => true, + } + } +} + +// Used to delimit content for tagged PDF. +#[elem(Locatable, Construct)] +pub struct PdfMarkerTag { + #[internal] + #[required] + pub kind: PdfMarkerTagKind, + #[required] + pub body: Content, +} + +impl Construct for PdfMarkerTag { + fn construct(_: &mut Engine, args: &mut Args) -> SourceResult { + bail!(args.span, "cannot be constructed manually"); + } +} + +macro_rules! pdf_marker_tag { + ($(#[doc = $doc:expr] $variant:ident$(($($name:ident: $ty:ident)+))?,)+) => { + #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] + pub enum PdfMarkerTagKind { + $( + #[doc = $doc] + $variant $(($($ty),+))? + ),+ + } + + impl PdfMarkerTag { + $( + #[doc = $doc] + #[allow(non_snake_case)] + pub fn $variant($($($name: $ty,)+)? body: Content) -> Content { + let span = body.span(); + Self { + kind: PdfMarkerTagKind::$variant $(($($name),+))?, + body, + }.pack().spanned(span) + } + )+ + } + } +} + +pdf_marker_tag! { + /// `TOC` + OutlineBody, + /// `Figure` + FigureBody, + /// `L` bibliography list + Bibliography(numbered: bool), + /// `LBody` wrapping `BibEntry` + BibEntry, + /// `Lbl` (marker) of the list item + ListItemLabel, + /// `LBody` of the enum item + ListItemBody, + /// A generic `Lbl` + Label, +} diff --git a/crates/typst-library/src/pdf/mod.rs b/crates/typst-library/src/pdf/mod.rs index 786a36372..869b20496 100644 --- a/crates/typst-library/src/pdf/mod.rs +++ b/crates/typst-library/src/pdf/mod.rs @@ -1,7 +1,9 @@ //! PDF-specific functionality. +mod accessibility; mod embed; +pub use self::accessibility::*; pub use self::embed::*; use crate::foundations::{Module, Scope}; @@ -11,5 +13,8 @@ pub fn module() -> Module { let mut pdf = Scope::deduplicating(); pdf.start_category(crate::Category::Pdf); pdf.define_elem::(); + pdf.define_elem::(); + pdf.define_func::(); + pdf.define_func::(); Module::new("pdf", pdf) } diff --git a/crates/typst-library/src/text/deco.rs b/crates/typst-library/src/text/deco.rs index d7383f0f1..ba9044071 100644 --- a/crates/typst-library/src/text/deco.rs +++ b/crates/typst-library/src/text/deco.rs @@ -1,4 +1,5 @@ use crate::foundations::{Content, Smart, elem}; +use crate::introspection::Locatable; use crate::layout::{Abs, Corners, Length, Rel, Sides}; use crate::text::{BottomEdge, BottomEdgeMetric, TopEdge, TopEdgeMetric}; use crate::visualize::{Color, FixedStroke, Paint, Stroke}; @@ -9,7 +10,7 @@ use crate::visualize::{Color, FixedStroke, Paint, Stroke}; /// ```example /// This is #underline[important]. /// ``` -#[elem] +#[elem(Locatable)] pub struct UnderlineElem { /// How to [stroke] the line. /// @@ -77,7 +78,7 @@ pub struct UnderlineElem { /// ```example /// #overline[A line over text.] /// ``` -#[elem] +#[elem(Locatable)] pub struct OverlineElem { /// How to [stroke] the line. /// @@ -151,7 +152,7 @@ pub struct OverlineElem { /// ```example /// This is #strike[not] relevant. /// ``` -#[elem(title = "Strikethrough")] +#[elem(title = "Strikethrough", Locatable)] pub struct StrikeElem { /// How to [stroke] the line. /// @@ -210,7 +211,7 @@ pub struct StrikeElem { /// ```example /// This is #highlight[important]. /// ``` -#[elem] +#[elem(Locatable)] pub struct HighlightElem { /// The color to highlight the text with. /// diff --git a/crates/typst-library/src/text/raw.rs b/crates/typst-library/src/text/raw.rs index 3d74ed0a6..536a1c139 100644 --- a/crates/typst-library/src/text/raw.rs +++ b/crates/typst-library/src/text/raw.rs @@ -20,6 +20,7 @@ use crate::foundations::{ Bytes, Content, Derived, OneOrMultiple, Packed, PlainText, ShowSet, Smart, StyleChain, Styles, Synthesize, cast, elem, scope, }; +use crate::introspection::Locatable; use crate::layout::{Em, HAlignment}; use crate::loading::{DataSource, Load}; use crate::model::{Figurable, ParElem}; @@ -77,6 +78,7 @@ use crate::visualize::Color; scope, title = "Raw Text / Code", Synthesize, + Locatable, ShowSet, LocalName, Figurable, @@ -612,7 +614,7 @@ fn format_theme_error(error: syntect::LoadingError) -> LoadError { /// It allows you to access various properties of the line, such as the line /// number, the raw non-highlighted text, the highlighted text, and whether it /// is the first or last line of the raw block. -#[elem(name = "line", title = "Raw Text / Code Line", PlainText)] +#[elem(name = "line", title = "Raw Text / Code Line", Locatable, PlainText)] pub struct RawLine { /// The line number of the raw line inside of the raw block, starts at 1. #[required] diff --git a/crates/typst-library/src/text/shift.rs b/crates/typst-library/src/text/shift.rs index eb4c89e18..6c8a103ea 100644 --- a/crates/typst-library/src/text/shift.rs +++ b/crates/typst-library/src/text/shift.rs @@ -1,3 +1,4 @@ +use crate::introspection::Locatable; use ttf_parser::Tag; use crate::foundations::{Content, Smart, elem}; @@ -12,7 +13,7 @@ use crate::text::{FontMetrics, ScriptMetrics, TextSize}; /// ```example /// Revenue#sub[yearly] /// ``` -#[elem(title = "Subscript")] +#[elem(title = "Subscript", Locatable)] pub struct SubElem { /// Whether to create artificial subscripts by lowering and scaling down /// regular glyphs. @@ -67,7 +68,7 @@ pub struct SubElem { /// ```example /// 1#super[st] try! /// ``` -#[elem(title = "Superscript")] +#[elem(title = "Superscript", Locatable)] pub struct SuperElem { /// Whether to create artificial superscripts by raising and scaling down /// regular glyphs. diff --git a/crates/typst-library/src/visualize/image/mod.rs b/crates/typst-library/src/visualize/image/mod.rs index 1a0e1e97a..1190fc9b0 100644 --- a/crates/typst-library/src/visualize/image/mod.rs +++ b/crates/typst-library/src/visualize/image/mod.rs @@ -26,6 +26,7 @@ use crate::foundations::{ Bytes, Cast, Content, Derived, NativeElement, Packed, Smart, StyleChain, cast, elem, func, scope, }; +use crate::introspection::Locatable; use crate::layout::{Length, Rel, Sizing}; use crate::loading::{DataSource, Load, LoadSource, Loaded, Readable}; use crate::model::Figurable; @@ -50,7 +51,7 @@ use crate::visualize::image::pdf::PdfDocument; /// ], /// ) /// ``` -#[elem(scope, LocalName, Figurable)] +#[elem(scope, Locatable, LocalName, Figurable)] pub struct ImageElem { /// A [path]($syntax/#paths) to an image file or raw bytes making up an /// image in one of the supported [formats]($image.format). @@ -127,7 +128,7 @@ pub struct ImageElem { /// The height of the image. pub height: Sizing, - /// A text describing the image. + /// An alternative description of the image. pub alt: Option, /// The page number that should be embedded as an image. This attribute only diff --git a/crates/typst-library/translations/ar.txt b/crates/typst-library/translations/ar.txt index 7af2aaa91..a39bf71a6 100644 --- a/crates/typst-library/translations/ar.txt +++ b/crates/typst-library/translations/ar.txt @@ -6,3 +6,4 @@ heading = الفصل outline = المحتويات raw = قائمة page = صفحة +# footnote = diff --git a/crates/typst-library/translations/bg.txt b/crates/typst-library/translations/bg.txt index e377af398..29aa03316 100644 --- a/crates/typst-library/translations/bg.txt +++ b/crates/typst-library/translations/bg.txt @@ -6,3 +6,4 @@ heading = Раздел outline = Съдържание raw = Приложение page = стр. +# footnote = diff --git a/crates/typst-library/translations/ca.txt b/crates/typst-library/translations/ca.txt index f02473293..880f83411 100644 --- a/crates/typst-library/translations/ca.txt +++ b/crates/typst-library/translations/ca.txt @@ -6,3 +6,4 @@ heading = Secció outline = Índex raw = Llistat page = pàgina +# footnote = diff --git a/crates/typst-library/translations/cs.txt b/crates/typst-library/translations/cs.txt index 417f1ab2e..f0986523f 100644 --- a/crates/typst-library/translations/cs.txt +++ b/crates/typst-library/translations/cs.txt @@ -6,3 +6,4 @@ heading = Kapitola outline = Obsah raw = Výpis page = strana +# footnote = diff --git a/crates/typst-library/translations/da.txt b/crates/typst-library/translations/da.txt index 4ceeda065..0ef36f3c2 100644 --- a/crates/typst-library/translations/da.txt +++ b/crates/typst-library/translations/da.txt @@ -6,3 +6,4 @@ heading = Afsnit outline = Indhold raw = Liste page = side +# footnote = diff --git a/crates/typst-library/translations/de.txt b/crates/typst-library/translations/de.txt index a9da1adb4..8d43f6706 100644 --- a/crates/typst-library/translations/de.txt +++ b/crates/typst-library/translations/de.txt @@ -6,3 +6,4 @@ heading = Abschnitt outline = Inhaltsverzeichnis raw = Listing page = Seite +footnote = Fußnote diff --git a/crates/typst-library/translations/el.txt b/crates/typst-library/translations/el.txt index 3853a45bb..05c8dd615 100644 --- a/crates/typst-library/translations/el.txt +++ b/crates/typst-library/translations/el.txt @@ -5,3 +5,4 @@ bibliography = Βιβλιογραφία heading = Κεφάλαιο outline = Περιεχόμενα raw = Παράθεση +# footnote = diff --git a/crates/typst-library/translations/en.txt b/crates/typst-library/translations/en.txt index fa2d65b91..21ae372a5 100644 --- a/crates/typst-library/translations/en.txt +++ b/crates/typst-library/translations/en.txt @@ -6,3 +6,4 @@ heading = Section outline = Contents raw = Listing page = page +footnote = Footnote diff --git a/crates/typst-library/translations/es.txt b/crates/typst-library/translations/es.txt index 8fe9929d8..0d95a3cb4 100644 --- a/crates/typst-library/translations/es.txt +++ b/crates/typst-library/translations/es.txt @@ -6,3 +6,4 @@ heading = Sección outline = Índice raw = Listado page = página +# footnote = diff --git a/crates/typst-library/translations/et.txt b/crates/typst-library/translations/et.txt index 588929052..0f1ea245b 100644 --- a/crates/typst-library/translations/et.txt +++ b/crates/typst-library/translations/et.txt @@ -6,3 +6,4 @@ heading = Peatükk outline = Sisukord raw = List page = lk. +# footnote = diff --git a/crates/typst-library/translations/eu.txt b/crates/typst-library/translations/eu.txt index d89f89b6f..257286873 100644 --- a/crates/typst-library/translations/eu.txt +++ b/crates/typst-library/translations/eu.txt @@ -6,3 +6,4 @@ heading = Atala outline = Aurkibidea raw = Kodea page = orria +# footnote = diff --git a/crates/typst-library/translations/fi.txt b/crates/typst-library/translations/fi.txt index edb88de8d..d0faa5e3d 100644 --- a/crates/typst-library/translations/fi.txt +++ b/crates/typst-library/translations/fi.txt @@ -6,3 +6,4 @@ heading = Osio outline = Sisällys raw = Esimerkki page = sivu +# footnote = diff --git a/crates/typst-library/translations/fr.txt b/crates/typst-library/translations/fr.txt index f8e27c9c0..4d08bf828 100644 --- a/crates/typst-library/translations/fr.txt +++ b/crates/typst-library/translations/fr.txt @@ -6,3 +6,4 @@ heading = Chapitre outline = Table des matières raw = Liste page = page +# footnote = diff --git a/crates/typst-library/translations/gl.txt b/crates/typst-library/translations/gl.txt index 49bf01b74..0f4918bc3 100644 --- a/crates/typst-library/translations/gl.txt +++ b/crates/typst-library/translations/gl.txt @@ -6,3 +6,4 @@ heading = Sección outline = Índice raw = Listado page = páxina +# footnote = diff --git a/crates/typst-library/translations/he.txt b/crates/typst-library/translations/he.txt index 5317c9278..c9b069c0c 100644 --- a/crates/typst-library/translations/he.txt +++ b/crates/typst-library/translations/he.txt @@ -6,3 +6,4 @@ heading = חלק outline = תוכן עניינים raw = קטע מקור page = עמוד +# footnote = diff --git a/crates/typst-library/translations/hr.txt b/crates/typst-library/translations/hr.txt index ea0754592..4243aa6d4 100644 --- a/crates/typst-library/translations/hr.txt +++ b/crates/typst-library/translations/hr.txt @@ -6,3 +6,4 @@ heading = Odjeljak outline = Sadržaj raw = Kôd page = str. +# footnote = diff --git a/crates/typst-library/translations/hu.txt b/crates/typst-library/translations/hu.txt index a88da3e54..fd7cb3485 100644 --- a/crates/typst-library/translations/hu.txt +++ b/crates/typst-library/translations/hu.txt @@ -6,3 +6,4 @@ heading = Fejezet outline = Tartalomjegyzék # raw = page = oldal +# footnote = diff --git a/crates/typst-library/translations/id.txt b/crates/typst-library/translations/id.txt index bea5ee18c..d3ce5818a 100644 --- a/crates/typst-library/translations/id.txt +++ b/crates/typst-library/translations/id.txt @@ -6,3 +6,4 @@ heading = Bagian outline = Daftar Isi raw = Kode page = halaman +# footnote = diff --git a/crates/typst-library/translations/is.txt b/crates/typst-library/translations/is.txt index 756c97700..b1bc8922a 100644 --- a/crates/typst-library/translations/is.txt +++ b/crates/typst-library/translations/is.txt @@ -6,3 +6,4 @@ heading = Kafli outline = Efnisyfirlit raw = Sýnishorn page = blaðsíða +# footnote = diff --git a/crates/typst-library/translations/it.txt b/crates/typst-library/translations/it.txt index 9f3c352db..9e282b0ff 100644 --- a/crates/typst-library/translations/it.txt +++ b/crates/typst-library/translations/it.txt @@ -6,3 +6,4 @@ heading = Sezione outline = Indice raw = Codice page = pag. +# footnote = diff --git a/crates/typst-library/translations/ja.txt b/crates/typst-library/translations/ja.txt index 484b20a62..8c01fb122 100644 --- a/crates/typst-library/translations/ja.txt +++ b/crates/typst-library/translations/ja.txt @@ -6,3 +6,4 @@ heading = 節 outline = 目次 raw = リスト page = ページ +# footnote = diff --git a/crates/typst-library/translations/la.txt b/crates/typst-library/translations/la.txt index d25517c2f..90912bf1a 100644 --- a/crates/typst-library/translations/la.txt +++ b/crates/typst-library/translations/la.txt @@ -6,3 +6,4 @@ heading = Caput outline = Index capitum raw = Exemplum page = charta +# footnote = diff --git a/crates/typst-library/translations/lv.txt b/crates/typst-library/translations/lv.txt index 4c6b86841..8d436fce1 100644 --- a/crates/typst-library/translations/lv.txt +++ b/crates/typst-library/translations/lv.txt @@ -6,3 +6,4 @@ heading = Sadaļa outline = Saturs raw = Saraksts page = lpp. +# footnote = diff --git a/crates/typst-library/translations/nb.txt b/crates/typst-library/translations/nb.txt index edf66b53f..0d718fd27 100644 --- a/crates/typst-library/translations/nb.txt +++ b/crates/typst-library/translations/nb.txt @@ -6,3 +6,4 @@ heading = Kapittel outline = Innhold raw = Utskrift page = side +# footnote = diff --git a/crates/typst-library/translations/nl.txt b/crates/typst-library/translations/nl.txt index 24b8315f0..d707031b5 100644 --- a/crates/typst-library/translations/nl.txt +++ b/crates/typst-library/translations/nl.txt @@ -6,3 +6,4 @@ heading = Hoofdstuk outline = Inhoudsopgave raw = Listing page = pagina +# footnote = diff --git a/crates/typst-library/translations/nn.txt b/crates/typst-library/translations/nn.txt index 2c2a27a76..7ccaae1cd 100644 --- a/crates/typst-library/translations/nn.txt +++ b/crates/typst-library/translations/nn.txt @@ -6,3 +6,4 @@ heading = Kapittel outline = Innhald raw = Utskrift page = side +# footnote = diff --git a/crates/typst-library/translations/pl.txt b/crates/typst-library/translations/pl.txt index cc8f4b36b..31a392b0d 100644 --- a/crates/typst-library/translations/pl.txt +++ b/crates/typst-library/translations/pl.txt @@ -6,3 +6,4 @@ heading = Sekcja outline = Spis treści raw = Program page = strona +# footnote = diff --git a/crates/typst-library/translations/pt-PT.txt b/crates/typst-library/translations/pt-PT.txt index 1d68ab858..56929b488 100644 --- a/crates/typst-library/translations/pt-PT.txt +++ b/crates/typst-library/translations/pt-PT.txt @@ -6,3 +6,4 @@ heading = Secção outline = Índice # raw = page = página +# footnote = diff --git a/crates/typst-library/translations/pt.txt b/crates/typst-library/translations/pt.txt index 398a75f37..3a579c73d 100644 --- a/crates/typst-library/translations/pt.txt +++ b/crates/typst-library/translations/pt.txt @@ -6,3 +6,4 @@ heading = Seção outline = Sumário raw = Listagem page = página +# footnote = diff --git a/crates/typst-library/translations/ro.txt b/crates/typst-library/translations/ro.txt index f5d44f726..89962e76a 100644 --- a/crates/typst-library/translations/ro.txt +++ b/crates/typst-library/translations/ro.txt @@ -7,3 +7,4 @@ outline = Cuprins # may be wrong raw = Listă page = pagina +# footnote = diff --git a/crates/typst-library/translations/ru.txt b/crates/typst-library/translations/ru.txt index 49cb34cb1..a9fab2548 100644 --- a/crates/typst-library/translations/ru.txt +++ b/crates/typst-library/translations/ru.txt @@ -6,3 +6,4 @@ heading = Раздел outline = Содержание raw = Листинг page = с. +# footnote = diff --git a/crates/typst-library/translations/sl.txt b/crates/typst-library/translations/sl.txt index 4c8a568ce..743639b7a 100644 --- a/crates/typst-library/translations/sl.txt +++ b/crates/typst-library/translations/sl.txt @@ -6,3 +6,4 @@ heading = Poglavje outline = Kazalo raw = Program page = stran +# footnote = diff --git a/crates/typst-library/translations/sq.txt b/crates/typst-library/translations/sq.txt index 11ba53212..37c0b2e51 100644 --- a/crates/typst-library/translations/sq.txt +++ b/crates/typst-library/translations/sq.txt @@ -6,3 +6,4 @@ heading = Kapitull outline = Përmbajtja raw = List page = faqe +# footnote = diff --git a/crates/typst-library/translations/sr.txt b/crates/typst-library/translations/sr.txt index e4e8f1272..2b6ee4021 100644 --- a/crates/typst-library/translations/sr.txt +++ b/crates/typst-library/translations/sr.txt @@ -6,3 +6,4 @@ heading = Поглавље outline = Садржај raw = Програм page = страна +# footnote = diff --git a/crates/typst-library/translations/sv.txt b/crates/typst-library/translations/sv.txt index 538f466b0..6ae8a582c 100644 --- a/crates/typst-library/translations/sv.txt +++ b/crates/typst-library/translations/sv.txt @@ -6,3 +6,4 @@ heading = Avsnitt outline = Innehåll raw = Kodlistning page = sida +# footnote = diff --git a/crates/typst-library/translations/tl.txt b/crates/typst-library/translations/tl.txt index 39cff5e36..e269d0289 100644 --- a/crates/typst-library/translations/tl.txt +++ b/crates/typst-library/translations/tl.txt @@ -6,3 +6,4 @@ heading = Seksyon outline = Talaan ng mga Nilalaman raw = Listahan # page = +# footnote = diff --git a/crates/typst-library/translations/tr.txt b/crates/typst-library/translations/tr.txt index f6e2cfe29..3e9b48675 100644 --- a/crates/typst-library/translations/tr.txt +++ b/crates/typst-library/translations/tr.txt @@ -6,3 +6,4 @@ heading = Bölüm outline = İçindekiler raw = Liste page = sayfa +# footnote = diff --git a/crates/typst-library/translations/uk.txt b/crates/typst-library/translations/uk.txt index 4794c3311..e87214bf5 100644 --- a/crates/typst-library/translations/uk.txt +++ b/crates/typst-library/translations/uk.txt @@ -6,3 +6,4 @@ heading = Розділ outline = Зміст raw = Лістинг page = c. +# footnote = diff --git a/crates/typst-library/translations/vi.txt b/crates/typst-library/translations/vi.txt index 8ccfdf02f..7b4aabfa6 100644 --- a/crates/typst-library/translations/vi.txt +++ b/crates/typst-library/translations/vi.txt @@ -7,3 +7,4 @@ outline = Mục lục # may be wrong raw = Chương trình page = trang +# footnote = diff --git a/crates/typst-library/translations/zh-TW.txt b/crates/typst-library/translations/zh-TW.txt index 4407f323e..e88753718 100644 --- a/crates/typst-library/translations/zh-TW.txt +++ b/crates/typst-library/translations/zh-TW.txt @@ -6,3 +6,4 @@ heading = 小節 outline = 目錄 raw = 程式 # page = +# footnote = diff --git a/crates/typst-library/translations/zh.txt b/crates/typst-library/translations/zh.txt index 32dc40107..a6f523ea0 100644 --- a/crates/typst-library/translations/zh.txt +++ b/crates/typst-library/translations/zh.txt @@ -6,3 +6,4 @@ heading = 小节 outline = 目录 raw = 代码 # page = +# footnote = diff --git a/crates/typst-pdf/Cargo.toml b/crates/typst-pdf/Cargo.toml index 5745d0530..e9a987dbf 100644 --- a/crates/typst-pdf/Cargo.toml +++ b/crates/typst-pdf/Cargo.toml @@ -19,6 +19,7 @@ typst-macros = { workspace = true } typst-syntax = { workspace = true } typst-timing = { workspace = true } typst-utils = { workspace = true } +az = { workspace = true } bytemuck = { workspace = true } comemo = { workspace = true } ecow = { workspace = true } @@ -27,6 +28,10 @@ infer = { workspace = true } krilla = { workspace = true } krilla-svg = { workspace = true } serde = { workspace = true } +smallvec = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } [lints] workspace = true diff --git a/crates/typst-pdf/src/convert.rs b/crates/typst-pdf/src/convert.rs index 12d1a78eb..182792a98 100644 --- a/crates/typst-pdf/src/convert.rs +++ b/crates/typst-pdf/src/convert.rs @@ -2,7 +2,6 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::num::NonZeroU64; use ecow::{EcoVec, eco_format}; -use krilla::annotation::Annotation; use krilla::configure::{Configuration, ValidationError, Validator}; use krilla::destination::{NamedDestination, XyzDestination}; use krilla::embed::EmbedError; @@ -11,11 +10,12 @@ use krilla::geom::PathBuilder; use krilla::page::{PageLabel, PageSettings}; use krilla::pdf::PdfError; use krilla::surface::Surface; +use krilla::tagging::TagId; use krilla::{Document, SerializeSettings}; use krilla_svg::render_svg_glyph; use typst_library::diag::{SourceDiagnostic, SourceResult, bail, error}; use typst_library::foundations::{NativeElement, Repr}; -use typst_library::introspection::Location; +use typst_library::introspection::{Location, Tag}; use typst_library::layout::{ Abs, Frame, FrameItem, GroupItem, PagedDocument, Size, Transform, }; @@ -27,11 +27,12 @@ use typst_syntax::Span; use crate::PdfOptions; use crate::embed::embed_files; use crate::image::handle_image; -use crate::link::handle_link; +use crate::link::{LinkAnnotation, handle_link}; use crate::metadata::build_metadata; use crate::outline::build_outline; use crate::page::PageLabelExt; use crate::shape::handle_shape; +use crate::tags::{self, Tags}; use crate::text::handle_text; use crate::util::{AbsExt, TransformExt, convert_path, display_font}; @@ -47,7 +48,7 @@ pub fn convert( xmp_metadata: true, cmyk_profile: None, configuration: options.standards.config, - enable_tagging: false, + enable_tagging: !options.disable_tags, render_svg_glyph_fn: render_svg_glyph, }; @@ -55,6 +56,7 @@ pub fn convert( let page_index_converter = PageIndexConverter::new(typst_document, options); let named_destinations = collect_named_destinations(typst_document, &page_index_converter); + let mut gc = GlobalContext::new( typst_document, options, @@ -67,6 +69,7 @@ pub fn convert( document.set_outline(build_outline(&gc)); document.set_metadata(build_metadata(&gc)); + document.set_tag_tree(gc.tags.build_tree()); finish(document, gc, options.standards.config) } @@ -106,6 +109,8 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul let mut surface = page.surface(); let mut fc = FrameContext::new(typst_page.frame.size()); + tags::page_start(gc, &mut surface); + handle_frame( &mut fc, &typst_page.frame, @@ -114,11 +119,11 @@ fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResul gc, )?; + tags::page_end(gc, &mut surface); + surface.finish(); - for annotation in fc.annotations { - page.add_annotation(annotation); - } + tags::add_annotations(gc, &mut page, fc.link_annotations); } } @@ -172,14 +177,14 @@ impl State { /// Context needed for converting a single frame. pub(crate) struct FrameContext { states: Vec, - annotations: Vec, + link_annotations: Vec, } impl FrameContext { pub(crate) fn new(size: Size) -> Self { Self { states: vec![State::new(size)], - annotations: vec![], + link_annotations: Vec::new(), } } @@ -199,8 +204,18 @@ impl FrameContext { self.states.last_mut().unwrap() } - pub(crate) fn push_annotation(&mut self, annotation: Annotation) { - self.annotations.push(annotation); + pub(crate) fn get_link_annotation( + &mut self, + link_id: tags::LinkId, + ) -> Option<&mut LinkAnnotation> { + self.link_annotations + .iter_mut() + .rev() + .find(|annot| annot.id == link_id) + } + + pub(crate) fn push_link_annotation(&mut self, annotation: LinkAnnotation) { + self.link_annotations.push(annotation); } } @@ -226,6 +241,8 @@ pub(crate) struct GlobalContext<'a> { /// The languages used throughout the document. pub(crate) languages: BTreeMap, pub(crate) page_index_converter: PageIndexConverter, + /// Tagged PDF context. + pub(crate) tags: Tags, } impl<'a> GlobalContext<'a> { @@ -245,6 +262,8 @@ impl<'a> GlobalContext<'a> { image_spans: HashSet::new(), languages: BTreeMap::new(), page_index_converter, + + tags: Tags::new(), } } } @@ -279,8 +298,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::Tag(_) => {} + FrameItem::Link(dest, size) => handle_link(fc, gc, dest, *size), + FrameItem::Tag(Tag::Start(elem)) => tags::handle_start(gc, surface, elem)?, + FrameItem::Tag(Tag::End(loc, _)) => tags::handle_end(gc, surface, *loc), } fc.pop(); @@ -295,7 +315,7 @@ pub(crate) fn handle_group( fc: &mut FrameContext, group: &GroupItem, surface: &mut Surface, - context: &mut GlobalContext, + gc: &mut GlobalContext, ) -> SourceResult<()> { fc.push(); fc.state_mut().pre_concat(group.transform); @@ -311,10 +331,12 @@ 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); } - handle_frame(fc, &group.frame, None, surface, context)?; + handle_frame(fc, &group.frame, None, surface, gc)?; if clip_path.is_some() { surface.pop(); @@ -353,6 +375,22 @@ fn finish( .collect::>(); Err(errors) } + 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(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); bail!(span, "failed to process image"); @@ -386,24 +424,24 @@ fn finish( } } } - KrillaError::DuplicateTagId(_, loc) => { - let span = to_span(loc); - bail!(span, - "duplicate tag id"; - hint: "please report this as a bug" - ); - } - KrillaError::UnknownTagId(_, loc) => { - let span = to_span(loc); - bail!(span, - "unknown tag id"; - hint: "please report this as a bug" - ); - } }, } } +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, @@ -572,16 +610,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"; diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index b846d1799..a8398d90e 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -5,6 +5,7 @@ use image::{DynamicImage, EncodableLayout, GenericImageView, Rgba}; use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; use krilla::pdf::PdfDocument; use krilla::surface::Surface; +use krilla::tagging::SpanTag; use krilla_svg::{SurfaceExt, SvgSettings}; use typst_library::diag::{SourceResult, bail}; use typst_library::foundations::Smart; @@ -15,6 +16,7 @@ use typst_library::visualize::{ use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; +use crate::tags; use crate::util::{SizeExt, TransformExt}; #[typst_macros::time(name = "handle image")] @@ -31,12 +33,11 @@ pub(crate) fn handle_image( let interpolate = image.scaling() == Smart::Custom(ImageScaling::Smooth); - if let Some(alt) = image.alt() { - surface.start_alt_text(alt); - } - gc.image_spans.insert(span); + let mut handle = + tags::start_span(gc, surface, SpanTag::empty().with_alt_text(image.alt())); + let surface = handle.surface(); match image.kind() { ImageKind::Raster(raster) => { let (exif_transform, new_size) = exif_transform(raster, size); @@ -66,10 +67,6 @@ pub(crate) fn handle_image( } } - if image.alt().is_some() { - surface.end_alt_text(); - } - surface.pop(); surface.reset_location(); diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index 96bbac591..09cde72aa 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -9,6 +9,7 @@ mod outline; mod page; mod paint; mod shape; +mod tags; mod text; mod util; @@ -53,6 +54,11 @@ pub struct PdfOptions<'a> { pub page_ranges: Option, /// A list of PDF standards that Typst will enforce conformance with. pub standards: PdfStandards, + /// By default, even when not producing a `PDF/UA-1` document, a tagged PDF + /// document is written to provide a baseline of accessibility. In some + /// circumstances, for example when trying to reduce the size of a document, + /// it can be desirable to disable tagged PDF. + pub disable_tags: bool, } /// Encapsulates a list of compatible PDF standards. @@ -104,6 +110,7 @@ impl PdfStandards { PdfStandard::A_4 => set_validator(Validator::A4)?, PdfStandard::A_4f => set_validator(Validator::A4F)?, PdfStandard::A_4e => set_validator(Validator::A4E)?, + PdfStandard::Ua_1 => set_validator(Validator::UA1)?, } } @@ -187,4 +194,7 @@ pub enum PdfStandard { /// PDF/A-4e. #[serde(rename = "a-4e")] A_4e, + /// PDF/UA-1. + #[serde(rename = "ua-1")] + Ua_1, } diff --git a/crates/typst-pdf/src/link.rs b/crates/typst-pdf/src/link.rs index 2133be82c..df1e926de 100644 --- a/crates/typst-pdf/src/link.rs +++ b/crates/typst-pdf/src/link.rs @@ -1,91 +1,103 @@ +use ecow::EcoString; use krilla::action::{Action, LinkAction}; -use krilla::annotation::{LinkAnnotation, Target}; +use krilla::annotation::Target; +use krilla::configure::Validator; use krilla::destination::XyzDestination; -use krilla::geom::Rect; -use typst_library::layout::{Abs, Point, Size}; +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}; use crate::util::{AbsExt, PointExt}; +pub(crate) struct LinkAnnotation { + pub(crate) id: tags::LinkId, + pub(crate) placeholder: Placeholder, + pub(crate) alt: Option, + pub(crate) quad_points: Vec, + pub(crate) target: Target, + pub(crate) span: Span, +} + pub(crate) fn handle_link( fc: &mut FrameContext, gc: &mut GlobalContext, dest: &Destination, size: 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(); - - let pos = Point::zero(); - - // Compute the bounding box of the transformed link. - for point in [ - pos, - pos + Point::with_x(size.x), - pos + Point::with_y(size.y), - pos + size.to_point(), - ] { - let t = point.transform(fc.state().transform()); - min_x.set_min(t.x); - min_y.set_min(t.y); - max_x.set_max(t.x); - max_y.set_max(t.y); - } - - let x1 = min_x.to_f32(); - let x2 = max_x.to_f32(); - let y1 = min_y.to_f32(); - let y2 = max_y.to_f32(); - - let rect = Rect::from_ltrb(x1, y1, x2, y2).unwrap(); - - // TODO: Support quad points. - - let pos = match dest { + let target = match dest { Destination::Url(u) => { - fc.push_annotation( - LinkAnnotation::new( - rect, - Target::Action(Action::Link(LinkAction::new(u.to_string()))), - ) - .into(), - ); - 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( - LinkAnnotation::new( - rect, - 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( - LinkAnnotation::new( - rect, - Target::Destination(krilla::destination::Destination::Xyz( - XyzDestination::new(index, pos.point.to_krilla()), - )), - ) - .into(), - ); + let Some((link_id, link, link_nodes)) = gc.tags.stack.find_parent_link() else { + 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, + // even by acrobat. Which is understandable since they were only introduced + // in PDF 1.6 (2005) /s + let should_use_quadpoints = gc.options.standards.config.validator() == Validator::UA1; + match fc.get_link_annotation(link_id) { + Some(annotation) if should_use_quadpoints => annotation.quad_points.push(quad), + _ => { + let placeholder = gc.tags.placeholders.reserve(); + link_nodes.push(TagNode::Placeholder(placeholder)); + fc.push_link_annotation(LinkAnnotation { + id: link_id, + placeholder, + quad_points: vec![quad], + alt, + target, + span: link.span, + }); + } } } + +/// Compute the quadrilateral representing the transformed rectangle of this frame. +fn to_quadrilateral(fc: &FrameContext, size: Size) -> kg::Quadrilateral { + let pos = Point::zero(); + let points = [ + pos + Point::with_y(size.y), + pos + size.to_point(), + pos + Point::with_x(size.x), + pos, + ]; + + kg::Quadrilateral(points.map(|point| { + let p = point.transform(fc.state().transform()); + kg::Point::from_xy(p.x.to_f32(), p.y.to_f32()) + })) +} + +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-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs index 5dc23563b..011abe921 100644 --- a/crates/typst-pdf/src/shape.rs +++ b/crates/typst-pdf/src/shape.rs @@ -1,12 +1,13 @@ use krilla::geom::{Path, PathBuilder, Rect}; use krilla::surface::Surface; use typst_library::diag::SourceResult; +use typst_library::pdf::ArtifactKind; use typst_library::visualize::{Geometry, Shape}; use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; -use crate::paint; use crate::util::{AbsExt, TransformExt, convert_path}; +use crate::{paint, tags}; #[typst_macros::time(name = "handle shape")] pub(crate) fn handle_shape( @@ -16,6 +17,9 @@ pub(crate) fn handle_shape( gc: &mut GlobalContext, span: Span, ) -> SourceResult<()> { + let mut handle = tags::start_artifact(gc, surface, ArtifactKind::Other); + let surface = handle.surface(); + surface.set_location(span.into_raw().get()); surface.push_transform(&fc.state().transform().to_krilla()); diff --git a/crates/typst-pdf/src/tags/list.rs b/crates/typst-pdf/src/tags/list.rs new file mode 100644 index 000000000..ce18fcd2f --- /dev/null +++ b/crates/typst-pdf/src/tags/list.rs @@ -0,0 +1,101 @@ +use krilla::tagging::{ListNumbering, TagKind}; + +use crate::tags::TagNode; + +#[derive(Debug)] +pub(crate) struct ListCtx { + numbering: ListNumbering, + items: Vec, +} + +#[derive(Debug)] +struct ListItem { + label: Vec, + body: Option>, + sub_list: Option, +} + +impl ListCtx { + pub(crate) fn new(numbering: ListNumbering) -> Self { + Self { numbering, items: Vec::new() } + } + + pub(crate) fn push_label(&mut self, nodes: Vec) { + self.items.push(ListItem { label: nodes, body: None, sub_list: None }); + } + + pub(crate) fn push_body(&mut self, mut nodes: Vec) { + let item = self.items.last_mut().expect("ListItemLabel"); + + // Nested lists are expected to have the following structure: + // + // Typst code + // ``` + // - a + // - b + // - c + // - d + // - e + // ``` + // + // Structure tree + // ``` + // + //
  • + // `-` + // `a` + //
  • + // `-` + // `b` + // + //
  • + // `-` + // `c` + //
  • + // `-` + // `d` + //
  • + // `-` + // `d` + // ``` + // + // So move the nested list out of the list item. + if let [_, TagNode::Group(tag, _)] = nodes.as_slice() { + if matches!(tag.kind, TagKind::L(_)) { + item.sub_list = nodes.pop(); + } + } + + item.body = Some(nodes); + } + + pub(crate) fn push_bib_entry(&mut self, nodes: Vec) { + let nodes = vec![TagNode::Group(TagKind::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); + } else { + self.items.push(ListItem { + label: Vec::new(), + body: Some(nodes), + sub_list: None, + }); + } + } + + pub(crate) fn build_list(self, mut nodes: Vec) -> TagNode { + for item in self.items.into_iter() { + nodes.push(TagNode::Group( + TagKind::LI.into(), + vec![ + TagNode::Group(TagKind::Lbl.into(), item.label), + TagNode::Group(TagKind::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) + } +} diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs new file mode 100644 index 000000000..159a2380b --- /dev/null +++ b/crates/typst-pdf/src/tags/mod.rs @@ -0,0 +1,666 @@ +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::page::Page; +use krilla::surface::Surface; +use krilla::tagging::{ + ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, TableDataCell, + Tag, TagBuilder, TagGroup, TagKind, TagTree, +}; +use typst_library::diag::SourceResult; +use typst_library::foundations::{ + Content, LinkMarker, NativeElement, Packed, RefableProperty, Settable, + SettableProperty, StyleChain, +}; +use typst_library::introspection::Location; +use typst_library::layout::RepeatElem; +use typst_library::math::EquationElem; +use typst_library::model::{ + Destination, EnumElem, FigureCaption, FigureElem, FootnoteElem, FootnoteEntry, + HeadingElem, ListElem, Outlinable, OutlineEntry, QuoteElem, TableCell, TableElem, + TermsElem, +}; +use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind}; +use typst_library::visualize::ImageElem; + +use crate::convert::GlobalContext; +use crate::link::LinkAnnotation; +use crate::tags::list::ListCtx; +use crate::tags::outline::OutlineCtx; +use crate::tags::table::TableCtx; + +mod list; +mod outline; +mod table; + +pub(crate) fn handle_start( + gc: &mut GlobalContext, + surface: &mut Surface, + elem: &Content, +) -> SourceResult<()> { + if gc.tags.in_artifact.is_some() { + // Don't nest artifacts + return Ok(()); + } + + let loc = elem.location().expect("elem to be locatable"); + + if let Some(artifact) = elem.to_packed::() { + let kind = artifact.kind.get(StyleChain::default()); + push_artifact(gc, surface, loc, kind); + return Ok(()); + } else if let Some(_) = elem.to_packed::() { + push_artifact(gc, surface, loc, ArtifactKind::Other); + return Ok(()); + } + + let tag: Tag = if let Some(tag) = elem.to_packed::() { + match tag.kind { + PdfMarkerTagKind::OutlineBody => { + push_stack(gc, loc, StackEntryKind::Outline(OutlineCtx::new()))?; + return Ok(()); + } + PdfMarkerTagKind::FigureBody => TagKind::Figure.into(), + PdfMarkerTagKind::Bibliography(numbered) => { + let numbering = + if numbered { ListNumbering::Decimal } else { ListNumbering::None }; + push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering)))?; + return Ok(()); + } + PdfMarkerTagKind::BibEntry => { + push_stack(gc, loc, StackEntryKind::BibEntry)?; + return Ok(()); + } + PdfMarkerTagKind::ListItemLabel => { + push_stack(gc, loc, StackEntryKind::ListItemLabel)?; + return Ok(()); + } + PdfMarkerTagKind::ListItemBody => { + push_stack(gc, loc, StackEntryKind::ListItemBody)?; + return Ok(()); + } + PdfMarkerTagKind::Label => TagKind::Lbl.into(), + } + } else if let Some(entry) = elem.to_packed::() { + push_stack(gc, loc, StackEntryKind::OutlineEntry(entry.clone()))?; + return Ok(()); + } else if let Some(_list) = elem.to_packed::() { + let numbering = ListNumbering::Circle; // TODO: infer numbering from `list.marker` + push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering)))?; + return Ok(()); + } else if let Some(_enumeration) = elem.to_packed::() { + let numbering = ListNumbering::Decimal; // TODO: infer numbering from `enum.numbering` + push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering)))?; + return Ok(()); + } else if let Some(_enumeration) = elem.to_packed::() { + let numbering = ListNumbering::None; + push_stack(gc, loc, StackEntryKind::List(ListCtx::new(numbering)))?; + return Ok(()); + } else if let Some(_) = elem.to_packed::() { + // Wrap the figure tag and the sibling caption in a container, if the + // 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() + } else if let Some(_) = elem.to_packed::() { + TagKind::Caption.into() + } else if let Some(image) = elem.to_packed::() { + 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 { + // Set alt text of outer figure tag, if not present. + if figure_tag.alt_text.is_none() { + figure_tag.alt_text = alt; + } + return Ok(()); + } else { + TagKind::Figure.with_alt_text(alt) + } + } else if let Some(equation) = elem.to_packed::() { + let alt = equation.alt.get_as_ref().map(|s| s.to_string()); + TagKind::Formula.with_alt_text(alt) + } else if let Some(table) = elem.to_packed::() { + let table_id = gc.tags.next_table_id(); + let summary = table.summary.get_as_ref().map(|s| s.to_string()); + let ctx = TableCtx::new(table_id, summary); + push_stack(gc, loc, StackEntryKind::Table(ctx))?; + return Ok(()); + } else if let Some(cell) = elem.to_packed::() { + let table_ctx = gc.tags.stack.parent_table(); + + // Only repeated table headers and footer cells are layed out multiple + // times. Mark duplicate headers as artifacts, since they have no + // semantic meaning in the tag tree, which doesn't use page breaks for + // it's semantic structure. + if table_ctx.is_some_and(|ctx| ctx.contains(cell)) { + // TODO: currently the first layouted cell is picked to be part of + // the tag tree, for repeating footers this will be the cell on the + // first page. Maybe it should be the cell on the last page, but that + // would require more changes in the layouting code, or a pre-pass + // on the frames to figure out if there are other footers following. + push_artifact(gc, surface, loc, ArtifactKind::Other); + } else { + push_stack(gc, loc, StackEntryKind::TableCell(cell.clone()))?; + } + return Ok(()); + } else if let Some(heading) = elem.to_packed::() { + let level = heading.level().try_into().unwrap_or(NonZeroU32::MAX); + let name = heading.body.plain_text().to_string(); + TagKind::Hn(level, Some(name)).into() + } else if let Some(link) = elem.to_packed::() { + let link_id = gc.tags.next_link_id(); + push_stack(gc, loc, StackEntryKind::Link(link_id, link.clone()))?; + return Ok(()); + } else if let Some(_) = elem.to_packed::() { + push_stack(gc, loc, StackEntryKind::FootNoteRef)?; + return Ok(()); + } else if let Some(entry) = elem.to_packed::() { + let footnote_loc = entry.note.location().unwrap(); + push_stack(gc, loc, StackEntryKind::FootNoteEntry(footnote_loc))?; + return Ok(()); + } else if let Some(quote) = elem.to_packed::() { + // TODO: should the attribution be handled somehow? + if quote.block.get(StyleChain::default()) { + TagKind::BlockQuote.into() + } else { + TagKind::InlineQuote.into() + } + } else { + return Ok(()); + }; + + let tag = tag.with_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 let Some((l, _)) = gc.tags.in_artifact { + if l == loc { + pop_artifact(gc, surface); + } + return; + } + + let Some(entry) = gc.tags.stack.pop_if(|e| e.loc == loc) else { + return; + }; + + let node = match entry.kind { + StackEntryKind::Standard(tag) => TagNode::Group(tag, entry.nodes), + StackEntryKind::Outline(ctx) => ctx.build_outline(entry.nodes), + StackEntryKind::OutlineEntry(outline_entry) => { + let Some((outline_ctx, outline_nodes)) = gc.tags.stack.parent_outline() + else { + // 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)); + return; + }; + + outline_ctx.insert(outline_nodes, outline_entry, entry.nodes); + return; + } + StackEntryKind::Table(ctx) => ctx.build_table(entry.nodes), + StackEntryKind::TableCell(cell) => { + let Some(table_ctx) = gc.tags.stack.parent_table() else { + // 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)); + return; + }; + + table_ctx.insert(&cell, entry.nodes); + return; + } + StackEntryKind::List(list) => list.build_list(entry.nodes), + StackEntryKind::ListItemLabel => { + let list_ctx = gc.tags.stack.parent_list().expect("parent list"); + list_ctx.push_label(entry.nodes); + return; + } + StackEntryKind::ListItemBody => { + let list_ctx = gc.tags.stack.parent_list().expect("parent list"); + list_ctx.push_body(entry.nodes); + return; + } + StackEntryKind::BibEntry => { + let list_ctx = gc.tags.stack.parent_list().expect("parent list"); + list_ctx.push_bib_entry(entry.nodes); + return; + } + 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); + // 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 + } + StackEntryKind::FootNoteRef => { + // transparently inset all children. + gc.tags.extend(entry.nodes); + gc.tags.push(TagNode::FootnoteEntry(loc)); + return; + } + 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); + gc.tags.footnotes.insert(footnote_loc, tag); + return; + } + }; + + gc.tags.push(node); +} + +fn push_stack( + gc: &mut GlobalContext, + loc: Location, + kind: StackEntryKind, +) -> SourceResult<()> { + if !gc.tags.context_supports(&kind) { + if gc.options.standards.config.validator() == Validator::UA1 { + // TODO: error + } else { + // TODO: warning + } + } + + gc.tags.stack.push(StackEntry { loc, kind, nodes: Vec::new() }); + + Ok(()) +} + +fn push_artifact( + gc: &mut GlobalContext, + surface: &mut Surface, + loc: Location, + kind: ArtifactKind, +) { + let ty = artifact_type(kind); + let id = surface.start_tagged(ContentTag::Artifact(ty)); + gc.tags.push(TagNode::Leaf(id)); + gc.tags.in_artifact = Some((loc, kind)); +} + +fn pop_artifact(gc: &mut GlobalContext, surface: &mut Surface) { + surface.end_tagged(); + gc.tags.in_artifact = None; +} + +pub(crate) fn page_start(gc: &mut GlobalContext, surface: &mut Surface) { + if let Some((_, kind)) = gc.tags.in_artifact { + let ty = artifact_type(kind); + let id = surface.start_tagged(ContentTag::Artifact(ty)); + gc.tags.push(TagNode::Leaf(id)); + } +} + +pub(crate) fn page_end(gc: &mut GlobalContext, surface: &mut Surface) { + 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( + gc: &mut GlobalContext, + page: &mut Page, + annotations: Vec, +) { + for annotation in annotations.into_iter() { + 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)); + } +} + +pub(crate) struct Tags { + /// The intermediary stack of nested tag groups. + pub(crate) stack: TagStack, + /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. + pub(crate) placeholders: Placeholders, + /// Footnotes are inserted directly after the footenote reference in the + /// reading order. Because of some layouting bugs, the entry might appear + /// before the reference in the text, so we only resolve them once tags + /// for the whole document are generated. + pub(crate) footnotes: HashMap, + pub(crate) in_artifact: Option<(Location, ArtifactKind)>, + /// Used to group multiple link annotations using quad points. + pub(crate) link_id: LinkId, + /// Used to generate IDs referenced in table `Headers` attributes. + /// The IDs must be document wide unique. + pub(crate) table_id: TableId, + + /// The output. + pub(crate) tree: Vec, +} + +impl Tags { + pub(crate) fn new() -> Self { + Self { + stack: TagStack(Vec::new()), + placeholders: Placeholders(Vec::new()), + footnotes: HashMap::new(), + in_artifact: None, + + link_id: LinkId(0), + table_id: TableId(0), + + tree: Vec::new(), + } + } + + pub(crate) fn push(&mut self, node: TagNode) { + if let Some(entry) = self.stack.last_mut() { + entry.nodes.push(node); + } else { + self.tree.push(node); + } + } + + pub(crate) fn extend(&mut self, nodes: impl IntoIterator) { + if let Some(entry) = self.stack.last_mut() { + entry.nodes.extend(nodes); + } else { + self.tree.extend(nodes); + } + } + + pub(crate) fn build_tree(&mut self) -> TagTree { + let children = std::mem::take(&mut self.tree) + .into_iter() + .map(|node| self.resolve_node(node)) + .collect::>(); + TagTree::from(children) + } + + /// Resolves [`Placeholder`] nodes. + fn resolve_node(&mut self, node: TagNode) -> Node { + match node { + TagNode::Group(tag, nodes) => { + let children = nodes + .into_iter() + .map(|node| self.resolve_node(node)) + .collect::>(); + Node::Group(TagGroup::with_children(tag, children)) + } + TagNode::Leaf(identifier) => Node::Leaf(identifier), + TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder), + TagNode::FootnoteEntry(loc) => { + let node = self.footnotes.remove(&loc).expect("footnote"); + self.resolve_node(node) + } + } + } + + fn context_supports(&self, _tag: &StackEntryKind) -> bool { + // TODO: generate using: https://pdfa.org/resource/iso-ts-32005-hierarchical-inclusion-rules/ + true + } + + fn next_link_id(&mut self) -> LinkId { + self.link_id.0 += 1; + self.link_id + } + + fn next_table_id(&mut self) -> TableId { + self.table_id.0 += 1; + self.table_id + } +} + +pub(crate) struct TagStack(Vec); + +impl Deref for TagStack { + type Target = Vec; + + 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 parent(&mut self) -> Option<&mut StackEntryKind> { + self.0.last_mut().map(|e| &mut e.kind) + } + + pub(crate) fn parent_table(&mut self) -> Option<&mut TableCtx> { + self.parent()?.as_table_mut() + } + + pub(crate) fn parent_list(&mut self) -> Option<&mut ListCtx> { + self.parent()?.as_list_mut() + } + + pub(crate) fn parent_outline( + &mut self, + ) -> Option<(&mut OutlineCtx, &mut Vec)> { + self.0.last_mut().and_then(|e| { + let ctx = e.kind.as_outline_mut()?; + Some((ctx, &mut e.nodes)) + }) + } + + pub(crate) fn find_parent_link( + &mut self, + ) -> Option<(LinkId, &LinkMarker, &mut Vec)> { + self.0.iter_mut().rev().find_map(|e| { + let (link_id, link) = e.kind.as_link()?; + Some((link_id, link.as_ref(), &mut e.nodes)) + }) + } +} + +pub(crate) struct Placeholders(Vec>); + +impl Placeholders { + pub(crate) fn reserve(&mut self) -> Placeholder { + let idx = self.0.len(); + self.0.push(OnceCell::new()); + Placeholder(idx) + } + + pub(crate) fn init(&mut self, placeholder: Placeholder, node: Node) { + self.0[placeholder.0] + .set(node) + .map_err(|_| ()) + .expect("placeholder to be uninitialized"); + } + + pub(crate) fn take(&mut self, placeholder: Placeholder) -> Node { + self.0[placeholder.0].take().expect("initialized placeholder node") + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct TableId(u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct LinkId(u32); + +#[derive(Debug)] +pub(crate) struct StackEntry { + pub(crate) loc: Location, + pub(crate) kind: StackEntryKind, + pub(crate) nodes: Vec, +} + +#[derive(Debug)] +pub(crate) enum StackEntryKind { + Standard(Tag), + Outline(OutlineCtx), + OutlineEntry(Packed), + Table(TableCtx), + TableCell(Packed), + List(ListCtx), + ListItemLabel, + ListItemBody, + BibEntry, + Link(LinkId, Packed), + /// The footnote reference in the text. + FootNoteRef, + /// The footnote entry at the end of the page. Contains the [`Location`] of + /// the [`FootnoteElem`](typst_library::model::FootnoteElem). + FootNoteEntry(Location), +} + +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 } + } + + pub(crate) fn as_table_mut(&mut self) -> Option<&mut TableCtx> { + if let Self::Table(v) = self { Some(v) } else { None } + } + + pub(crate) fn as_list_mut(&mut self) -> Option<&mut ListCtx> { + if let Self::List(v) = self { Some(v) } else { None } + } + + pub(crate) fn as_link(&self) -> Option<(LinkId, &Packed)> { + if let Self::Link(id, link) = self { Some((*id, link)) } else { None } + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) enum TagNode { + Group(Tag, Vec), + Leaf(Identifier), + /// Allows inserting a placeholder into the tag tree. + /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. + Placeholder(Placeholder), + FootnoteEntry(Location), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Placeholder(usize); + +/// Automatically calls [`Surface::end_tagged`] when dropped. +pub(crate) struct TagHandle<'a, 'b> { + surface: &'b mut Surface<'a>, + /// Whether this tag handle started the marked content sequence, and should + /// thus end it when it is dropped. + started: bool, +} + +impl Drop for TagHandle<'_, '_> { + fn drop(&mut self) { + if self.started { + self.surface.end_tagged(); + } + } +} + +impl<'a> TagHandle<'a, '_> { + pub(crate) fn surface<'c>(&'c mut self) -> &'c mut Surface<'a> { + self.surface + } +} + +/// 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>( + gc: &mut GlobalContext, + surface: &'b mut Surface<'a>, + span: SpanTag, +) -> TagHandle<'a, 'b> { + start_content(gc, surface, ContentTag::Span(span)) +} + +/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`] +/// when dropped. +pub(crate) fn start_artifact<'a, 'b>( + gc: &mut GlobalContext, + surface: &'b mut Surface<'a>, + kind: ArtifactKind, +) -> TagHandle<'a, 'b> { + let ty = artifact_type(kind); + start_content(gc, surface, ContentTag::Artifact(ty)) +} + +fn start_content<'a, 'b>( + gc: &mut GlobalContext, + surface: &'b mut Surface<'a>, + content: ContentTag, +) -> TagHandle<'a, 'b> { + 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) { + // Mark any direct child of a table as an aritfact. Any real content + // will be wrapped inside a `TableCell`. + ContentTag::Artifact(ArtifactType::Other) + } else { + content + }; + let id = surface.start_tagged(content); + gc.tags.push(TagNode::Leaf(id)); + TagHandle { surface, started: true } +} + +fn artifact_type(kind: ArtifactKind) -> ArtifactType { + match kind { + ArtifactKind::Header => ArtifactType::Header, + ArtifactKind::Footer => ArtifactType::Footer, + ArtifactKind::Page => ArtifactType::Page, + ArtifactKind::Other => ArtifactType::Other, + } +} + +trait PropertyGetAsRef { + fn get_as_ref(&self) -> Option<&T>; +} + +impl PropertyGetAsRef for Settable +where + E: NativeElement, + E: SettableProperty>, + E: RefableProperty, +{ + fn get_as_ref(&self) -> Option<&T> { + self.get_ref(StyleChain::default()).as_ref() + } +} diff --git a/crates/typst-pdf/src/tags/outline.rs b/crates/typst-pdf/src/tags/outline.rs new file mode 100644 index 000000000..e809489f3 --- /dev/null +++ b/crates/typst-pdf/src/tags/outline.rs @@ -0,0 +1,73 @@ +use krilla::tagging::TagKind; +use typst_library::foundations::Packed; +use typst_library::model::OutlineEntry; + +use crate::tags::TagNode; + +#[derive(Debug)] +pub(crate) struct OutlineCtx { + stack: Vec, +} + +impl OutlineCtx { + pub(crate) fn new() -> Self { + Self { stack: Vec::new() } + } + + pub(crate) fn insert( + &mut self, + outline_nodes: &mut Vec, + entry: Packed, + nodes: Vec, + ) { + let expected_len = entry.level.get() - 1; + if self.stack.len() < expected_len { + self.stack.resize_with(expected_len, OutlineSection::new); + } else { + while self.stack.len() > expected_len { + self.finish_section(outline_nodes); + } + } + + let section_entry = TagNode::Group(TagKind::TOCI.into(), nodes); + self.push(outline_nodes, section_entry); + } + + fn finish_section(&mut self, outline_nodes: &mut Vec) { + let sub_section = self.stack.pop().unwrap().into_tag(); + self.push(outline_nodes, sub_section); + } + + fn push(&mut self, outline_nodes: &mut Vec, entry: TagNode) { + match self.stack.last_mut() { + Some(section) => section.push(entry), + None => outline_nodes.push(entry), + } + } + + pub(crate) fn build_outline(mut self, mut outline_nodes: Vec) -> TagNode { + while !self.stack.is_empty() { + self.finish_section(&mut outline_nodes); + } + TagNode::Group(TagKind::TOC.into(), outline_nodes) + } +} + +#[derive(Debug)] +pub(crate) struct OutlineSection { + entries: Vec, +} + +impl OutlineSection { + const fn new() -> Self { + OutlineSection { entries: Vec::new() } + } + + fn push(&mut self, entry: TagNode) { + self.entries.push(entry); + } + + fn into_tag(self) -> TagNode { + TagNode::Group(TagKind::TOC.into(), self.entries) + } +} diff --git a/crates/typst-pdf/src/tags/table.rs b/crates/typst-pdf/src/tags/table.rs new file mode 100644 index 000000000..5cbdaae9f --- /dev/null +++ b/crates/typst-pdf/src/tags/table.rs @@ -0,0 +1,582 @@ +use std::io::Write as _; +use std::num::NonZeroU32; + +use az::SaturatingAs; +use krilla::tagging::{ + TableCellSpan, TableDataCell, TableHeaderCell, TagBuilder, 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}; + +#[derive(Debug)] +pub(crate) struct TableCtx { + pub(crate) id: TableId, + pub(crate) summary: Option, + rows: Vec>, + min_width: usize, +} + +impl TableCtx { + pub(crate) fn new(id: TableId, summary: Option) -> Self { + Self { id, summary, rows: Vec::new(), min_width: 0 } + } + + fn get(&self, x: usize, y: usize) -> Option<&TableCtxCell> { + let cell = self.rows.get(y)?.get(x)?; + self.resolve_cell(cell) + } + + fn get_mut(&mut self, x: usize, y: usize) -> Option<&mut TableCtxCell> { + let cell = self.rows.get_mut(y)?.get_mut(x)?; + match cell { + GridCell::Cell(cell) => { + // HACK: Workaround for the second mutable borrow when resolving + // the spanned cell. + Some(unsafe { std::mem::transmute(cell) }) + } + &mut GridCell::Spanned(x, y) => self.rows[y][x].as_cell_mut(), + GridCell::Missing => None, + } + } + + pub(crate) fn contains(&self, cell: &Packed) -> bool { + let x = cell.x.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); + let y = cell.y.get(StyleChain::default()).unwrap_or_else(|| unreachable!()); + self.get(x, y).is_some() + } + + fn resolve_cell<'a>(&'a self, cell: &'a GridCell) -> Option<&'a TableCtxCell> { + match cell { + GridCell::Cell(cell) => Some(cell), + &GridCell::Spanned(x, y) => self.rows[y][x].as_cell(), + GridCell::Missing => None, + } + } + + pub(crate) fn insert(&mut self, cell: &Packed, nodes: Vec) { + 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()); + let colspan = cell.colspan.get(StyleChain::default()); + let kind = cell.kind.get(StyleChain::default()); + + // Extend the table grid to fit this cell. + let required_height = y + rowspan.get(); + self.min_width = self.min_width.max(x + colspan.get()); + if self.rows.len() < required_height { + self.rows + .resize(required_height, vec![GridCell::Missing; self.min_width]); + } + for row in self.rows.iter_mut() { + if row.len() < self.min_width { + row.resize_with(self.min_width, || GridCell::Missing); + } + } + + // Store references to the cell for all spanned cells. + for i in y..y + rowspan.get() { + for j in x..x + colspan.get() { + self.rows[i][j] = GridCell::Spanned(x, y); + } + } + + self.rows[y][x] = GridCell::Cell(TableCtxCell { + x: x.saturating_as(), + y: y.saturating_as(), + rowspan: rowspan.try_into().unwrap_or(NonZeroU32::MAX), + colspan: colspan.try_into().unwrap_or(NonZeroU32::MAX), + kind, + headers: SmallVec::new(), + nodes, + span: cell.span(), + }); + } + + pub(crate) fn build_table(mut self, mut nodes: Vec) -> TagNode { + // 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); + } + let height = self.rows.len(); + let width = self.rows[0].len(); + + // Only generate row groups such as `THead`, `TFoot`, and `TBody` if + // there are no rows with mixed cell kinds. + let mut gen_row_groups = true; + let row_kinds = (self.rows.iter()) + .map(|row| { + row.iter() + .filter_map(|cell| self.resolve_cell(cell)) + .map(|cell| cell.kind) + .fold(Smart::Auto, |a, b| { + if let Smart::Custom(TableCellKind::Header(_, scope)) = b { + gen_row_groups &= scope == TableHeaderScope::Column; + } + if let (Smart::Custom(a), Smart::Custom(b)) = (a, b) { + gen_row_groups &= a == b; + } + a.or(b) + }) + .unwrap_or(TableCellKind::Data) + }) + .collect::>(); + + // Fixup all missing cell kinds. + for (row, row_kind) in self.rows.iter_mut().zip(row_kinds.iter().copied()) { + let default_kind = + if gen_row_groups { row_kind } else { TableCellKind::Data }; + for cell in row.iter_mut() { + let Some(cell) = cell.as_cell_mut() else { continue }; + cell.kind = cell.kind.or(Smart::Custom(default_kind)); + } + } + + // Explicitly set the headers attribute for cells. + for x in 0..width { + let mut column_header = Vec::new(); + for y in 0..height { + self.resolve_cell_headers( + (x, y), + &mut column_header, + TableHeaderScope::refers_to_column, + ); + } + } + for y in 0..height { + let mut row_header = Vec::new(); + for x in 0..width { + self.resolve_cell_headers( + (x, y), + &mut row_header, + TableHeaderScope::refers_to_row, + ); + } + } + + let mut chunk_kind = row_kinds[0]; + let mut row_chunk = Vec::new(); + for (row, row_kind) in self.rows.into_iter().zip(row_kinds) { + let row_nodes = row + .into_iter() + .filter_map(|cell| { + let cell = cell.into_cell()?; + let span = TableCellSpan { rows: cell.rowspan, cols: 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), + ) + .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), + ) + .with_location(Some(cell.span.into_raw().get())), + }; + Some(TagNode::Group(tag, cell.nodes)) + }) + .collect(); + + let row = TagNode::Group(TagKind::TR.into(), row_nodes); + + // Push the `TR` tags directly. + if !gen_row_groups { + nodes.push(row); + continue; + } + + // 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, + }; + nodes.push(TagNode::Group(tag.into(), std::mem::take(&mut row_chunk))); + + chunk_kind = row_kind; + } + row_chunk.push(row); + } + + if !row_chunk.is_empty() { + let tag = match chunk_kind { + TableCellKind::Header(..) => TagKind::THead, + TableCellKind::Footer => TagKind::TFoot, + TableCellKind::Data => TagKind::TBody, + }; + nodes.push(TagNode::Group(tag.into(), row_chunk)); + } + + TagNode::Group(TagKind::Table(self.summary).into(), nodes) + } + + fn resolve_cell_headers( + &mut self, + (x, y): (usize, usize), + current_header: &mut Vec<(NonZeroU32, TagId)>, + refers_to_dir: F, + ) where + F: Fn(&TableHeaderScope) -> bool, + { + let table_id = self.id; + let Some(cell) = self.get_mut(x, y) else { return }; + + let mut new_header = None; + if let TableCellKind::Header(level, scope) = cell.unwrap_kind() { + if refers_to_dir(&scope) { + // Remove all headers that are the same or a lower level. + while current_header.pop_if(|(l, _)| *l >= level).is_some() {} + + let tag_id = table_cell_id(table_id, cell.x, cell.y); + new_header = Some((level, tag_id)); + } + } + + if let Some((_, cell_id)) = current_header.last() { + if !cell.headers.contains(&cell_id) { + cell.headers.push(cell_id.clone()); + } + } + + current_header.extend(new_header); + } +} + +#[derive(Clone, Debug, Default)] +enum GridCell { + Cell(TableCtxCell), + Spanned(usize, usize), + #[default] + Missing, +} + +impl GridCell { + fn as_cell(&self) -> Option<&TableCtxCell> { + if let Self::Cell(v) = self { Some(v) } else { None } + } + + fn as_cell_mut(&mut self) -> Option<&mut TableCtxCell> { + if let Self::Cell(v) = self { Some(v) } else { None } + } + + fn into_cell(self) -> Option { + if let Self::Cell(v) = self { Some(v) } else { None } + } +} + +#[derive(Clone, Debug)] +struct TableCtxCell { + x: u32, + y: u32, + rowspan: NonZeroU32, + colspan: NonZeroU32, + kind: Smart, + headers: SmallVec<[TagId; 1]>, + nodes: Vec, + span: Span, +} + +impl TableCtxCell { + fn unwrap_kind(&self) -> TableCellKind { + self.kind.unwrap_or_else(|| unreachable!()) + } +} + +fn should_group_rows(a: TableCellKind, b: TableCellKind) -> bool { + match (a, b) { + (TableCellKind::Header(..), TableCellKind::Header(..)) => true, + (TableCellKind::Footer, TableCellKind::Footer) => true, + (TableCellKind::Data, TableCellKind::Data) => true, + (_, _) => false, + } +} + +fn table_cell_id(table_id: TableId, x: u32, y: u32) -> TagId { + let mut buf = SmallVec::<[u8; 32]>::new(); + _ = write!(&mut buf, "{}x{x}y{y}", table_id.0); + TagId::from(buf) +} + +fn table_header_scope(scope: TableHeaderScope) -> krilla::tagging::TableHeaderScope { + match scope { + TableHeaderScope::Both => krilla::tagging::TableHeaderScope::Both, + TableHeaderScope::Column => krilla::tagging::TableHeaderScope::Column, + TableHeaderScope::Row => krilla::tagging::TableHeaderScope::Row, + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use typst_library::foundations::Content; + + use super::*; + + #[track_caller] + fn test(table: TableCtx, exp_tag: TagNode) { + let tag = table.build_table(Vec::new()); + assert_eq!(exp_tag, tag); + } + + #[track_caller] + fn table(cells: [TableCell; SIZE]) -> TableCtx { + let mut table = TableCtx::new(TableId(324), Some("summary".into())); + for cell in cells { + table.insert(&Packed::new(cell), Vec::new()); + } + table + } + + #[track_caller] + fn header_cell( + (x, y): (usize, usize), + level: u32, + scope: TableHeaderScope, + ) -> TableCell { + TableCell::new(Content::default()) + .with_x(Smart::Custom(x)) + .with_y(Smart::Custom(y)) + .with_kind(Smart::Custom(TableCellKind::Header( + NonZeroU32::new(level).unwrap(), + scope, + ))) + } + + #[track_caller] + fn footer_cell(x: usize, y: usize) -> TableCell { + TableCell::new(Content::default()) + .with_x(Smart::Custom(x)) + .with_y(Smart::Custom(y)) + .with_kind(Smart::Custom(TableCellKind::Footer)) + } + + fn cell(x: usize, y: usize) -> TableCell { + TableCell::new(Content::default()) + .with_x(Smart::Custom(x)) + .with_y(Smart::Custom(y)) + .with_kind(Smart::Custom(TableCellKind::Data)) + } + + fn empty_cell(x: usize, y: usize) -> TableCell { + TableCell::new(Content::default()) + .with_x(Smart::Custom(x)) + .with_y(Smart::Custom(y)) + .with_kind(Smart::Auto) + } + + fn table_tag(nodes: [TagNode; SIZE]) -> TagNode { + let tag = TagKind::Table(Some("summary".into())); + TagNode::Group(tag.into(), nodes.into()) + } + + fn thead(nodes: [TagNode; SIZE]) -> TagNode { + TagNode::Group(TagKind::THead.into(), nodes.into()) + } + + fn tbody(nodes: [TagNode; SIZE]) -> TagNode { + TagNode::Group(TagKind::TBody.into(), nodes.into()) + } + + fn tfoot(nodes: [TagNode; SIZE]) -> TagNode { + TagNode::Group(TagKind::TFoot.into(), nodes.into()) + } + + fn trow(nodes: [TagNode; SIZE]) -> TagNode { + TagNode::Group(TagKind::TR.into(), nodes.into()) + } + + fn th( + (x, y): (u32, u32), + scope: TableHeaderScope, + headers: [(u32, u32); SIZE], + ) -> TagNode { + let scope = table_header_scope(scope); + 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)) + .with_location(Some(Span::detached().into_raw().get())), + Vec::new(), + ) + } + + fn td(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())), + Vec::new(), + ) + } + + #[test] + fn simple_table() { + #[rustfmt::skip] + let table = table([ + header_cell((0, 0), 1, TableHeaderScope::Column), + header_cell((1, 0), 1, TableHeaderScope::Column), + header_cell((2, 0), 1, TableHeaderScope::Column), + + cell(0, 1), + cell(1, 1), + cell(2, 1), + + cell(0, 2), + cell(1, 2), + cell(2, 2), + ]); + + #[rustfmt::skip] + let tag = table_tag([ + thead([trow([ + th((0, 0), TableHeaderScope::Column, []), + th((1, 0), TableHeaderScope::Column, []), + th((2, 0), TableHeaderScope::Column, []), + ])]), + tbody([ + trow([ + td([(0, 0)]), + td([(1, 0)]), + td([(2, 0)]), + ]), + trow([ + td([(0, 0)]), + td([(1, 0)]), + td([(2, 0)]), + ]), + ]), + ]); + + test(table, tag); + } + + #[test] + fn header_row_and_column() { + #[rustfmt::skip] + let table = table([ + header_cell((0, 0), 1, TableHeaderScope::Column), + header_cell((1, 0), 1, TableHeaderScope::Column), + header_cell((2, 0), 1, TableHeaderScope::Column), + + header_cell((0, 1), 1, TableHeaderScope::Row), + cell(1, 1), + cell(2, 1), + + header_cell((0, 2), 1, TableHeaderScope::Row), + cell(1, 2), + cell(2, 2), + ]); + + #[rustfmt::skip] + let tag = table_tag([ + trow([ + th((0, 0), TableHeaderScope::Column, []), + th((1, 0), TableHeaderScope::Column, []), + th((2, 0), TableHeaderScope::Column, []), + ]), + trow([ + th((0, 1), TableHeaderScope::Row, [(0, 0)]), + td([(1, 0), (0, 1)]), + td([(2, 0), (0, 1)]), + ]), + trow([ + th((0, 2), TableHeaderScope::Row, [(0, 0)]), + td([(1, 0), (0, 2)]), + td([(2, 0), (0, 2)]), + ]), + ]); + + test(table, tag); + } + + #[test] + fn complex_tables() { + #[rustfmt::skip] + let table = table([ + header_cell((0, 0), 1, TableHeaderScope::Column), + header_cell((1, 0), 1, TableHeaderScope::Column), + header_cell((2, 0), 1, TableHeaderScope::Column), + + header_cell((0, 1), 2, TableHeaderScope::Column), + header_cell((1, 1), 2, TableHeaderScope::Column), + header_cell((2, 1), 2, TableHeaderScope::Column), + + cell(0, 2), + empty_cell(1, 2), // the type of empty cells is inferred from the row + cell(2, 2), + + header_cell((0, 3), 2, TableHeaderScope::Column), + header_cell((1, 3), 2, TableHeaderScope::Column), + empty_cell(2, 3), // the type of empty cells is inferred from the row + + cell(0, 4), + cell(1, 4), + empty_cell(2, 4), + + empty_cell(0, 5), // the type of empty cells is inferred from the row + footer_cell(1, 5), + footer_cell(2, 5), + ]); + + #[rustfmt::skip] + let tag = table_tag([ + thead([ + trow([ + th((0, 0), TableHeaderScope::Column, []), + th((1, 0), TableHeaderScope::Column, []), + th((2, 0), TableHeaderScope::Column, []), + ]), + trow([ + th((0, 1), TableHeaderScope::Column, [(0, 0)]), + th((1, 1), TableHeaderScope::Column, [(1, 0)]), + th((2, 1), TableHeaderScope::Column, [(2, 0)]), + ]), + ]), + tbody([ + trow([ + td([(0, 1)]), + td([(1, 1)]), + td([(2, 1)]), + ]), + ]), + thead([ + trow([ + th((0, 3), TableHeaderScope::Column, [(0, 0)]), + th((1, 3), TableHeaderScope::Column, [(1, 0)]), + th((2, 3), TableHeaderScope::Column, [(2, 0)]), + ]), + ]), + tbody([ + trow([ + td([(0, 3)]), + td([(1, 3)]), + td([(2, 3)]), + ]), + ]), + tfoot([ + trow([ + td([(0, 3)]), + td([(1, 3)]), + td([(2, 3)]), + ]), + ]), + ]); + + test(table, tag); + } +} diff --git a/crates/typst-pdf/src/text.rs b/crates/typst-pdf/src/text.rs index 8bce738ce..675f64640 100644 --- a/crates/typst-pdf/src/text.rs +++ b/crates/typst-pdf/src/text.rs @@ -11,8 +11,8 @@ use typst_library::visualize::FillRule; use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; -use crate::paint; use crate::util::{AbsExt, TransformExt, display_font}; +use crate::{paint, tags}; #[typst_macros::time(name = "handle text")] pub(crate) fn handle_text( @@ -23,6 +23,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); + let surface = handle.surface(); + let font = convert_font(gc, t.font.clone())?; let fill = paint::convert_fill( gc,