From b312b45b19ea75440e34fd7b514eaab4d908ae12 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Fri, 1 Aug 2025 16:02:58 +0200 Subject: [PATCH] feat: underline, overline, and strike text decoration attributes Artifact content ids are no longer added to the tag tree. This greatly simplifies the grouping of text items into span tags with text decoration attributes. Artifact IDs are dummy ids anyway and more of an implementation detail of how krilla writes marked content/artifacts. --- crates/typst-pdf/src/image.rs | 9 +- crates/typst-pdf/src/lib.rs | 9 + crates/typst-pdf/src/shape.rs | 5 +- crates/typst-pdf/src/tags/context.rs | 211 +++++++++++++++--- crates/typst-pdf/src/tags/mod.rs | 184 ++++++++++----- crates/typst-pdf/src/text.rs | 9 +- .../ref/pdftags/deco-tags-different-color.yml | 13 ++ .../deco-tags-different-stroke-thickness.yml | 13 ++ .../ref/pdftags/deco-tags-different-type.yml | 14 ++ tests/ref/pdftags/deco-tags-underline.yml | 15 ++ tests/ref/pdftags/figure-basic.yml | 12 - tests/ref/pdftags/grid-headers.yml | 75 ------- tests/ref/pdftags/table-tags-basic.yml | 8 - .../table-tags-column-and-row-header.yml | 8 - .../ref/pdftags/table-tags-missing-cells.yml | 11 - tests/suite/pdftags/deco.typ | 26 +++ 16 files changed, 415 insertions(+), 207 deletions(-) create mode 100644 tests/ref/pdftags/deco-tags-different-color.yml create mode 100644 tests/ref/pdftags/deco-tags-different-stroke-thickness.yml create mode 100644 tests/ref/pdftags/deco-tags-different-type.yml create mode 100644 tests/ref/pdftags/deco-tags-underline.yml create mode 100644 tests/suite/pdftags/deco.typ diff --git a/crates/typst-pdf/src/image.rs b/crates/typst-pdf/src/image.rs index 89b11b118..50e016272 100644 --- a/crates/typst-pdf/src/image.rs +++ b/crates/typst-pdf/src/image.rs @@ -5,11 +5,10 @@ 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; -use typst_library::layout::{Abs, Angle, Point, Ratio, Rect, Size, Transform}; +use typst_library::layout::{Abs, Angle, Ratio, Size, Transform}; use typst_library::visualize::{ ExchangeFormat, Image, ImageKind, ImageScaling, PdfImage, RasterFormat, RasterImage, }; @@ -35,11 +34,9 @@ pub(crate) fn handle_image( gc.image_spans.insert(span); - tags::update_bbox(gc, fc, || Rect::from_pos_size(Point::zero(), size)); - - let mut handle = - tags::start_span(gc, surface, SpanTag::empty().with_alt_text(image.alt())); + let mut handle = tags::image(gc, fc, surface, image, size); let surface = handle.surface(); + match image.kind() { ImageKind::Raster(raster) => { let (exif_transform, new_size) = exif_transform(raster, size); diff --git a/crates/typst-pdf/src/lib.rs b/crates/typst-pdf/src/lib.rs index fb43d7336..5d5be8f57 100644 --- a/crates/typst-pdf/src/lib.rs +++ b/crates/typst-pdf/src/lib.rs @@ -18,6 +18,7 @@ pub use self::metadata::{Timestamp, Timezone}; use std::fmt::{self, Debug, Formatter}; use ecow::eco_format; +use krilla::configure::Validator; use serde::{Deserialize, Serialize}; use typst_library::diag::{SourceResult, StrResult, bail}; use typst_library::foundations::Smart; @@ -67,6 +68,14 @@ pub struct PdfOptions<'a> { pub disable_tags: bool, } +impl PdfOptions<'_> { + /// Whether the current export mode is PDF/UA-1, and in the future maybe + /// PDF/UA-2. + pub fn is_pdf_ua(&self) -> bool { + self.standards.config.validator() == Validator::UA1 + } +} + /// Encapsulates a list of compatible PDF standards. #[derive(Clone)] pub struct PdfStandards { diff --git a/crates/typst-pdf/src/shape.rs b/crates/typst-pdf/src/shape.rs index 854667844..2fa5daceb 100644 --- a/crates/typst-pdf/src/shape.rs +++ b/crates/typst-pdf/src/shape.rs @@ -1,7 +1,6 @@ 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; @@ -17,9 +16,7 @@ pub(crate) fn handle_shape( gc: &mut GlobalContext, span: Span, ) -> SourceResult<()> { - tags::update_bbox(gc, fc, || shape.geometry.bbox()); - - let mut handle = tags::start_artifact(gc, surface, ArtifactKind::Other); + let mut handle = tags::shape(gc, fc, surface, shape); let surface = handle.surface(); surface.set_location(span.into_raw()); diff --git a/crates/typst-pdf/src/tags/context.rs b/crates/typst-pdf/src/tags/context.rs index c9ecb0997..6e11867ab 100644 --- a/crates/typst-pdf/src/tags/context.rs +++ b/crates/typst-pdf/src/tags/context.rs @@ -3,25 +3,32 @@ use std::collections::HashMap; use std::slice::SliceIndex; use krilla::geom as kg; -use krilla::tagging::{BBox, Node, TagKind, TagTree}; -use typst_library::foundations::{LinkMarker, Packed}; +use krilla::tagging::{ + BBox, Identifier, LineHeight, NaiveRgbColor, Node, Tag, TagKind, TagTree, + TextDecorationType, +}; +use typst_library::diag::{SourceResult, bail}; +use typst_library::foundations::{Content, LinkMarker, Packed}; use typst_library::introspection::Location; -use typst_library::layout::{Abs, Point, Rect}; +use typst_library::layout::{Abs, Length, Point, Rect}; use typst_library::model::{OutlineEntry, TableCell}; use typst_library::pdf::ArtifactKind; use typst_library::text::Lang; use typst_syntax::Span; +use crate::PdfOptions; use crate::convert::FrameContext; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::table::TableCtx; -use crate::tags::{Placeholder, TagGroup, TagNode}; +use crate::tags::{Placeholder, TagNode}; use crate::util::AbsExt; pub struct Tags { /// The language of the first text item that has been encountered. pub doc_lang: Option, + /// The current set of text attributes. + pub text_attrs: TextAttrs, /// The intermediary stack of nested tag groups. pub stack: TagStack, /// A list of placeholders corresponding to a [`TagNode::Placeholder`]. @@ -39,13 +46,14 @@ pub struct Tags { table_id: TableId, /// The output. - pub tree: Vec, + tree: Vec, } impl Tags { pub fn new() -> Self { Self { doc_lang: None, + text_attrs: TextAttrs::new(), stack: TagStack::new(), placeholders: Placeholders(Vec::new()), footnotes: HashMap::new(), @@ -66,6 +74,23 @@ impl Tags { } } + pub fn push_text(&mut self, new_attrs: ResolvedTextAttrs, id: Identifier) { + // FIXME: Artifacts will force a split in the spans, and decoartions + // generate artifacts + let last_node = if let Some(entry) = self.stack.last_mut() { + entry.nodes.last_mut() + } else { + self.tree.last_mut() + }; + if let Some(TagNode::Text(prev_attrs, nodes)) = last_node + && *prev_attrs == new_attrs + { + nodes.push(id); + } else { + self.push(TagNode::Text(new_attrs, vec![id])); + } + } + pub fn extend(&mut self, nodes: impl IntoIterator) { if let Some(entry) = self.stack.last_mut() { entry.nodes.extend(nodes); @@ -77,11 +102,11 @@ impl Tags { pub fn build_tree(&mut self) -> TagTree { assert!(self.stack.items.is_empty(), "tags weren't properly closed"); - let children = std::mem::take(&mut self.tree) - .into_iter() - .map(|node| self.resolve_node(node)) - .collect::>(); - TagTree::from(children) + let mut nodes = Vec::new(); + for child in std::mem::take(&mut self.tree) { + self.resolve_node(&mut nodes, child); + } + TagTree::from(nodes) } /// Try to set the language of a parent tag, or the entire document. @@ -89,10 +114,6 @@ impl Tags { /// this will return `Some`, and the language should be specified on the /// marked content directly. pub fn try_set_lang(&mut self, lang: Lang) -> Option { - // Discard languages within artifacts. - if self.in_artifact.is_some() { - return None; - } if self.doc_lang.is_none_or(|l| l == lang) { self.doc_lang = Some(lang); return None; @@ -106,23 +127,46 @@ impl Tags { Some(lang) } - /// Resolves [`Placeholder`] nodes. - fn resolve_node(&mut self, node: TagNode) -> Node { + /// Resolves nodes into an accumulator. + fn resolve_node(&mut self, accum: &mut Vec, node: TagNode) { match node { - TagNode::Group(TagGroup { tag, nodes }) => { - let children = nodes - .into_iter() - .map(|node| self.resolve_node(node)) - .collect::>(); - Node::Group(krilla::tagging::TagGroup::with_children(tag, children)) + TagNode::Group(group) => { + let mut nodes = Vec::new(); + for child in group.nodes { + self.resolve_node(&mut nodes, child); + } + let group = krilla::tagging::TagGroup::with_children(group.tag, nodes); + accum.push(Node::Group(group)); + } + TagNode::Leaf(identifier) => { + accum.push(Node::Leaf(identifier)); + } + TagNode::Placeholder(placeholder) => { + accum.push(self.placeholders.take(placeholder)); } - TagNode::Leaf(identifier) => Node::Leaf(identifier), - TagNode::Placeholder(placeholder) => self.placeholders.take(placeholder), TagNode::FootnoteEntry(loc) => { let node = (self.footnotes.remove(&loc)) .and_then(|ctx| ctx.entry) .expect("footnote"); - self.resolve_node(node) + self.resolve_node(accum, node) + } + TagNode::Text(attrs, ids) => { + let children = ids.into_iter().map(|id| Node::Leaf(id)); + if attrs.is_empty() { + accum.extend(children); + } else { + let tag = Tag::Span + .with_line_height(attrs.lineheight) + .with_baseline_shift(attrs.baseline_shift) + .with_text_decoration_type(attrs.deco.map(|d| d.kind.to_krilla())) + .with_text_decoration_color(attrs.deco.and_then(|d| d.color)) + .with_text_decoration_thickness( + attrs.deco.and_then(|d| d.thickness), + ); + let group = + krilla::tagging::TagGroup::with_children(tag, children.collect()); + accum.push(Node::Group(group)); + } } } } @@ -143,6 +187,123 @@ impl Tags { } } +#[derive(Clone, Debug)] +pub struct TextAttrs { + lineheight: Option, + baseline_shift: Option, + /// PDF can only represent one of the following attributes at a time. + /// Keep track of all of them, and depending if PDF/UA-1 is enforced, either + /// throw an error, or just use one of them. + decos: Vec<(Location, TextDeco)>, +} + +impl TextAttrs { + pub fn new() -> Self { + Self { + lineheight: None, + baseline_shift: None, + decos: Vec::new(), + } + } + + pub fn push_deco( + &mut self, + options: &PdfOptions, + elem: &Content, + kind: TextDecoKind, + stroke: TextDecoStroke, + ) -> SourceResult<()> { + let deco = TextDeco { kind, stroke }; + + // TODO: can overlapping tags break this? + if self.decos.iter().any(|(_, d)| d.kind != deco.kind) { + let validator = options.standards.config.validator(); + let validator = validator.as_str(); + bail!( + elem.span(), + "{validator} error: cannot combine underline, overline, and or strike" + ); + } + + let loc = elem.location().unwrap(); + self.decos.push((loc, deco)); + Ok(()) + } + + /// Returns true if a decoration was removed. + pub fn pop_deco(&mut self, loc: Location) -> bool { + // TODO: Ideally we would just check the top of the stack, can + // overlapping tags even happen for decorations? + if let Some(i) = self.decos.iter().rposition(|(l, _)| *l == loc) { + self.decos.remove(i); + return true; + } + false + } + + pub fn resolve(&self, em: Abs) -> ResolvedTextAttrs { + let deco = self.decos.last().map(|&(_, TextDeco { kind, stroke })| { + let thickness = stroke.thickness.map(|t| t.at(em).to_f32()); + ResolvedTextDeco { kind, color: stroke.color, thickness } + }); + + ResolvedTextAttrs { + lineheight: self.lineheight, + baseline_shift: self.baseline_shift, + deco, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct TextDeco { + kind: TextDecoKind, + stroke: TextDecoStroke, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TextDecoKind { + Underline, + Overline, + Strike, +} + +impl TextDecoKind { + fn to_krilla(self) -> TextDecorationType { + match self { + TextDecoKind::Underline => TextDecorationType::Underline, + TextDecoKind::Overline => TextDecorationType::Overline, + TextDecoKind::Strike => TextDecorationType::LineThrough, + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct TextDecoStroke { + pub color: Option, + pub thickness: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedTextAttrs { + lineheight: Option, + baseline_shift: Option, + deco: Option, +} + +impl ResolvedTextAttrs { + pub fn is_empty(&self) -> bool { + self.lineheight.is_none() && self.baseline_shift.is_none() && self.deco.is_none() + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ResolvedTextDeco { + kind: TextDecoKind, + color: Option, + thickness: Option, +} + #[derive(Debug)] pub struct TagStack { items: Vec, diff --git a/crates/typst-pdf/src/tags/mod.rs b/crates/typst-pdf/src/tags/mod.rs index e2b5d975f..6ea34d274 100644 --- a/crates/typst-pdf/src/tags/mod.rs +++ b/crates/typst-pdf/src/tags/mod.rs @@ -5,12 +5,13 @@ use krilla::configure::Validator; use krilla::page::Page; use krilla::surface::Surface; use krilla::tagging::{ - ArtifactType, ContentTag, Identifier, ListNumbering, Node, SpanTag, Tag, TagKind, + ArtifactType, ContentTag, Identifier, ListNumbering, NaiveRgbColor, Node, SpanTag, + Tag, TagKind, }; use typst_library::diag::{SourceResult, bail}; -use typst_library::foundations::{Content, LinkMarker}; +use typst_library::foundations::{Content, LinkMarker, Smart}; use typst_library::introspection::Location; -use typst_library::layout::{Rect, RepeatElem}; +use typst_library::layout::{Point, Rect, RepeatElem, Size}; use typst_library::math::EquationElem; use typst_library::model::{ Destination, EnumElem, FigureCaption, FigureElem, FootnoteEntry, HeadingElem, @@ -18,8 +19,10 @@ use typst_library::model::{ TermsElem, }; use typst_library::pdf::{ArtifactElem, ArtifactKind, PdfMarkerTag, PdfMarkerTagKind}; -use typst_library::text::{Lang, RawElem}; -use typst_library::visualize::ImageElem; +use typst_library::text::{ + Lang, OverlineElem, RawElem, StrikeElem, TextItem, UnderlineElem, +}; +use typst_library::visualize::{Image, ImageElem, Paint, Shape, Stroke}; use typst_syntax::Span; use crate::convert::{FrameContext, GlobalContext}; @@ -27,7 +30,7 @@ use crate::link::LinkAnnotation; use crate::tags::list::ListCtx; use crate::tags::outline::OutlineCtx; use crate::tags::table::TableCtx; -use crate::tags::util::{PropertyOptRef, PropertyValCopied}; +use crate::tags::util::{PropertyOptRef, PropertyValCloned, PropertyValCopied}; pub use context::*; @@ -45,6 +48,9 @@ pub enum TagNode { /// Currently used for [`krilla::page::Page::add_tagged_annotation`]. Placeholder(Placeholder), FootnoteEntry(Location), + /// If the attributes are non-empty this will resolve to a [`Tag::Span`], + /// otherwise the items are inserted directly. + Text(ResolvedTextAttrs, Vec), } impl TagNode { @@ -108,7 +114,7 @@ pub fn handle_start( return Ok(()); } - let mut tag: TagKind = if let Some(tag) = elem.to_packed::() { + let tag = if let Some(tag) = elem.to_packed::() { match &tag.kind { PdfMarkerTagKind::OutlineBody => { push_stack(gc, elem, StackEntryKind::Outline(OutlineCtx::new()))?; @@ -239,16 +245,50 @@ pub fn handle_start( }); push_stack(gc, elem, StackEntryKind::Code(desc))?; return Ok(()); + } else if let Some(underline) = elem.to_packed::() { + let kind = TextDecoKind::Underline; + let stroke = deco_stroke(underline.stroke.val_cloned()); + gc.tags.text_attrs.push_deco(gc.options, elem, kind, stroke)?; + return Ok(()); + } else if let Some(overline) = elem.to_packed::() { + let kind = TextDecoKind::Overline; + let stroke = deco_stroke(overline.stroke.val_cloned()); + gc.tags.text_attrs.push_deco(gc.options, elem, kind, stroke)?; + return Ok(()); + } else if let Some(strike) = elem.to_packed::() { + let kind = TextDecoKind::Strike; + let stroke = deco_stroke(strike.stroke.val_cloned()); + gc.tags.text_attrs.push_deco(gc.options, elem, kind, stroke)?; + return Ok(()); } else { return Ok(()); }; - tag.set_location(Some(elem.span().into_raw())); push_stack(gc, elem, StackEntryKind::Standard(tag))?; Ok(()) } +fn deco_stroke(stroke: Smart) -> TextDecoStroke { + let Smart::Custom(stroke) = stroke else { + return TextDecoStroke::default(); + }; + let color = stroke.paint.custom().and_then(|paint| match paint { + Paint::Solid(color) => { + let c = color.to_rgb(); + Some(NaiveRgbColor::new(c.red, c.green, c.blue)) + } + // TODO: Don't fail silently, maybe make a best effort to convert a + // gradient to a single solid color? + Paint::Gradient(_) => None, + // TODO: Don't fail silently, maybe just error in PDF/UA mode? + Paint::Tiling(_) => None, + }); + + let thickness = stroke.thickness.custom(); + TextDecoStroke { color, thickness } +} + fn push_stack( gc: &mut GlobalContext, elem: &Content, @@ -280,8 +320,7 @@ fn push_artifact( ) { let loc = elem.location().expect("elem to be locatable"); let ty = artifact_type(kind); - let id = surface.start_tagged(ContentTag::Artifact(ty)); - gc.tags.push(TagNode::Leaf(id)); + surface.start_tagged(ContentTag::Artifact(ty)); gc.tags.in_artifact = Some((loc, kind)); } @@ -307,6 +346,10 @@ pub fn handle_end( return Ok(()); } + if gc.tags.text_attrs.pop_deco(loc) { + return Ok(()); + } + // Search for an improperly nested starting tag, that is being closed. let Some(idx) = (gc.tags.stack.iter().enumerate()) .rev() @@ -318,11 +361,10 @@ pub fn handle_end( // There are overlapping tags in the tag tree. Figure whether breaking // up the current tag stack is semantically ok. - let is_pdf_ua = gc.options.standards.config.validator() == Validator::UA1; let mut is_breakable = true; let mut non_breakable_span = Span::detached(); for e in gc.tags.stack[idx + 1..].iter() { - if e.kind.is_breakable(is_pdf_ua) { + if e.kind.is_breakable(gc.options.is_pdf_ua()) { continue; } @@ -333,12 +375,12 @@ pub fn handle_end( } } if !is_breakable { - let validator = gc.options.standards.config.validator(); - if is_pdf_ua { - let ua1 = validator.as_str(); + if gc.options.is_pdf_ua() { + let validator = gc.options.standards.config.validator(); + let validator = validator.as_str(); bail!( non_breakable_span, - "{ua1} error: invalid semantic structure, \ + "{validator} error: invalid semantic structure, \ this element's tag would be split up"; hint: "maybe this is caused by a `parbreak`, `colbreak`, or `pagebreak`" ); @@ -503,8 +545,7 @@ pub 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)); + surface.start_tagged(ContentTag::Artifact(ty)); } } @@ -543,7 +584,66 @@ pub fn add_link_annotations( } } -pub fn update_bbox( +pub fn text<'a, 'b>( + gc: &mut GlobalContext, + fc: &FrameContext, + surface: &'b mut Surface<'a>, + text: &TextItem, +) -> TagHandle<'a, 'b> { + if gc.options.disable_tags { + return TagHandle { surface, started: false }; + } + + update_bbox(gc, fc, || text.bbox()); + + if gc.tags.in_artifact.is_some() { + return TagHandle { surface, started: false }; + } + + let attrs = gc.tags.text_attrs.resolve(text.size); + + // Marked content + let lang = gc.tags.try_set_lang(text.lang); + let lang = lang.as_ref().map(Lang::as_str); + let content = ContentTag::Span(SpanTag::empty().with_lang(lang)); + let id = surface.start_tagged(content); + + gc.tags.push_text(attrs, id); + + TagHandle { surface, started: true } +} + +pub fn image<'a, 'b>( + gc: &mut GlobalContext, + fc: &FrameContext, + surface: &'b mut Surface<'a>, + image: &Image, + size: Size, +) -> TagHandle<'a, 'b> { + if gc.options.disable_tags { + return TagHandle { surface, started: false }; + } + + update_bbox(gc, fc, || Rect::from_pos_size(Point::zero(), size)); + let content = ContentTag::Span(SpanTag::empty().with_alt_text(image.alt())); + start_content(gc, surface, content) +} + +pub fn shape<'a, 'b>( + gc: &mut GlobalContext, + fc: &FrameContext, + surface: &'b mut Surface<'a>, + shape: &Shape, +) -> TagHandle<'a, 'b> { + if gc.options.disable_tags { + return TagHandle { surface, started: false }; + } + + update_bbox(gc, fc, || shape.geometry.bbox()); + start_content(gc, surface, ContentTag::Artifact(ArtifactType::Other)) +} + +fn update_bbox( gc: &mut GlobalContext, fc: &FrameContext, compute_bbox: impl FnOnce() -> Rect, @@ -577,48 +677,30 @@ impl<'a> TagHandle<'a, '_> { } } -/// Returns a [`TagHandle`] that automatically calls [`Surface::end_tagged`] -/// when dropped. -pub 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 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> { - if gc.options.disable_tags { - return TagHandle { surface, started: false }; - } - - let content = if gc.tags.in_artifact.is_some() { + 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) { + // TODO: handle this more like other artifacts // Mark any direct child of a table as an aritfact. Any real content // will be wrapped inside a `TableCell`. - ContentTag::Artifact(ArtifactType::Other) + + // Don't store artifact content ids, they will be omitted anyway when + // serializing the tag tree. + surface.start_tagged(ContentTag::Artifact(ArtifactType::Other)); + TagHandle { surface, started: true } } else { - content - }; - let id = surface.start_tagged(content); - gc.tags.push(TagNode::Leaf(id)); - TagHandle { surface, started: true } + let artifact = matches!(content, ContentTag::Artifact(_)); + let id = surface.start_tagged(content); + if !artifact { + gc.tags.push(TagNode::Leaf(id)); + } + TagHandle { surface, started: true } + } } fn artifact_type(kind: ArtifactKind) -> ArtifactType { diff --git a/crates/typst-pdf/src/text.rs b/crates/typst-pdf/src/text.rs index f5f8b2782..7be2eb577 100644 --- a/crates/typst-pdf/src/text.rs +++ b/crates/typst-pdf/src/text.rs @@ -3,11 +3,10 @@ use std::sync::Arc; use bytemuck::TransparentWrapper; use krilla::surface::{Location, Surface}; -use krilla::tagging::SpanTag; use krilla::text::GlyphId; use typst_library::diag::{SourceResult, bail}; use typst_library::layout::Size; -use typst_library::text::{Font, Glyph, Lang, TextItem}; +use typst_library::text::{Font, Glyph, TextItem}; use typst_library::visualize::FillRule; use typst_syntax::Span; @@ -22,11 +21,7 @@ pub(crate) fn handle_text( surface: &mut Surface, gc: &mut GlobalContext, ) -> SourceResult<()> { - let lang = gc.tags.try_set_lang(t.lang); - let lang = lang.as_ref().map(Lang::as_str); - tags::update_bbox(gc, fc, || t.bbox()); - - let mut handle = tags::start_span(gc, surface, SpanTag::empty().with_lang(lang)); + let mut handle = tags::text(gc, fc, surface, t); let surface = handle.surface(); let font = convert_font(gc, t.font.clone())?; diff --git a/tests/ref/pdftags/deco-tags-different-color.yml b/tests/ref/pdftags/deco-tags-different-color.yml new file mode 100644 index 000000000..9e52b7d41 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-different-color.yml @@ -0,0 +1,13 @@ +- Tag: P + /K: + - Tag: Span + /TextDecorationColor: #ff4136 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=0 + - Tag: Span + /TextDecorationColor: #0074d9 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/deco-tags-different-stroke-thickness.yml b/tests/ref/pdftags/deco-tags-different-stroke-thickness.yml new file mode 100644 index 000000000..07b658be9 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-different-stroke-thickness.yml @@ -0,0 +1,13 @@ +- Tag: P + /K: + - Tag: Span + /TextDecorationThickness: 2.000 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=0 + - Tag: Span + /TextDecorationThickness: 1.000 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=1 + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/deco-tags-different-type.yml b/tests/ref/pdftags/deco-tags-different-type.yml new file mode 100644 index 000000000..2d26ea434 --- /dev/null +++ b/tests/ref/pdftags/deco-tags-different-type.yml @@ -0,0 +1,14 @@ +- Tag: P + /K: + - Tag: Span + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=0 + - Tag: Span + /TextDecorationType: Overline + /K: + - Content: page=0 mcid=1 + - Tag: Span + /TextDecorationType: LineThrough + /K: + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/deco-tags-underline.yml b/tests/ref/pdftags/deco-tags-underline.yml new file mode 100644 index 000000000..774825d4b --- /dev/null +++ b/tests/ref/pdftags/deco-tags-underline.yml @@ -0,0 +1,15 @@ +- Tag: P + /K: + - Tag: Span + /TextDecorationColor: #ff4136 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=0 + - Content: page=0 mcid=1 +- Tag: P + /K: + - Tag: Span + /TextDecorationColor: #ff4136 + /TextDecorationType: Underline + /K: + - Content: page=0 mcid=2 diff --git a/tests/ref/pdftags/figure-basic.yml b/tests/ref/pdftags/figure-basic.yml index 39a7111f7..a12329e4c 100644 --- a/tests/ref/pdftags/figure-basic.yml +++ b/tests/ref/pdftags/figure-basic.yml @@ -36,11 +36,6 @@ right: 90.250 bottom: 71.820 /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: TBody /K: - Tag: TR @@ -88,13 +83,6 @@ right: 97.635 bottom: 199.833 /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: TBody /K: - Tag: TR diff --git a/tests/ref/pdftags/grid-headers.yml b/tests/ref/pdftags/grid-headers.yml index 786ee46cd..83883cae4 100644 --- a/tests/ref/pdftags/grid-headers.yml +++ b/tests/ref/pdftags/grid-headers.yml @@ -1,80 +1,5 @@ - Tag: Table /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: THead /K: - Tag: TR diff --git a/tests/ref/pdftags/table-tags-basic.yml b/tests/ref/pdftags/table-tags-basic.yml index 035982722..d8616c888 100644 --- a/tests/ref/pdftags/table-tags-basic.yml +++ b/tests/ref/pdftags/table-tags-basic.yml @@ -6,14 +6,6 @@ right: 76.350 bottom: 60.240 /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: THead /K: - Tag: TR diff --git a/tests/ref/pdftags/table-tags-column-and-row-header.yml b/tests/ref/pdftags/table-tags-column-and-row-header.yml index eac8100f7..c5741e5f2 100644 --- a/tests/ref/pdftags/table-tags-column-and-row-header.yml +++ b/tests/ref/pdftags/table-tags-column-and-row-header.yml @@ -6,14 +6,6 @@ right: 85.360 bottom: 60.240 /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: TR /K: - Tag: TH diff --git a/tests/ref/pdftags/table-tags-missing-cells.yml b/tests/ref/pdftags/table-tags-missing-cells.yml index 8b031729d..66330ee42 100644 --- a/tests/ref/pdftags/table-tags-missing-cells.yml +++ b/tests/ref/pdftags/table-tags-missing-cells.yml @@ -6,17 +6,6 @@ right: 76.350 bottom: 96.820 /K: - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - - Artifact - Tag: THead /K: - Tag: TR diff --git a/tests/suite/pdftags/deco.typ b/tests/suite/pdftags/deco.typ new file mode 100644 index 000000000..8f98f71c5 --- /dev/null +++ b/tests/suite/pdftags/deco.typ @@ -0,0 +1,26 @@ +--- deco-tags-underline pdftags --- +#show: underline.with(stroke: red) + +// The content in this paragraph is grouped into one span tag with the +// corresponding text attributes. +red underlined text +red underlined text + +red underlined text + +--- deco-tags-different-color pdftags --- +#show: underline.with(stroke: red) +red underlined text +#show: underline.with(stroke: blue) +blue underlined text + +--- deco-tags-different-stroke-thickness pdftags --- +#show: underline.with(stroke: 2pt) +red underlined text +#show: underline.with(stroke: 1pt) +blue underlined text + +--- deco-tags-different-type pdftags --- +#underline[underlined]\ +#overline[overlined]\ +#strike[striked]\