From b2d9bd2fb4a112b20dd718ddbf68ddd30d659ee2 Mon Sep 17 00:00:00 2001 From: Tobias Schmitz Date: Fri, 1 Aug 2025 16:02:58 +0200 Subject: [PATCH] feat: [WIP] text decoration attributes --- 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 | 228 ++++++++++++++++++++++++--- crates/typst-pdf/src/tags/mod.rs | 168 +++++++++++++++----- crates/typst-pdf/src/text.rs | 9 +- tests/suite/pdftags/deco.typ | 7 + 7 files changed, 353 insertions(+), 82 deletions(-) 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..e16729029 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 krilla::tagging::{ + BBox, Identifier, LineHeight, NaiveRgbColor, Node, Tag, TagKind, TagTree, + TextDecorationType, +}; +use typst_library::diag::{StrResult, bail}; use typst_library::foundations::{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,142 @@ 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_underline( + &mut self, + options: &PdfOptions, + loc: Location, + stroke: TextDecoStroke, + ) -> StrResult<()> { + self.push_deco(options, loc, TextDeco { kind: TextDecoKind::Underline, stroke }) + } + + pub fn push_overline( + &mut self, + options: &PdfOptions, + loc: Location, + stroke: TextDecoStroke, + ) -> StrResult<()> { + self.push_deco(options, loc, TextDeco { kind: TextDecoKind::Overline, stroke }) + } + + pub fn push_strike( + &mut self, + options: &PdfOptions, + loc: Location, + stroke: TextDecoStroke, + ) -> StrResult<()> { + self.push_deco(options, loc, TextDeco { kind: TextDecoKind::Strike, stroke }) + } + + pub fn push_deco( + &mut self, + options: &PdfOptions, + loc: Location, + deco: TextDeco, + ) -> StrResult<()> { + // 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!("{validator} error: cannot combine underline, overline, and or strike"); + } + 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..7055fbe96 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::diag::{At, SourceResult, bail}; +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,59 @@ pub fn handle_start( }); push_stack(gc, elem, StackEntryKind::Code(desc))?; return Ok(()); + } else if let Some(underline) = elem.to_packed::() { + let loc = elem.location().unwrap(); + let stroke = deco_stroke(underline.stroke.val_cloned()); + gc.tags + .text_attrs + .push_underline(gc.options, loc, stroke) + .at(elem.span())?; + return Ok(()); + } else if let Some(overline) = elem.to_packed::() { + let loc = elem.location().unwrap(); + let stroke = deco_stroke(overline.stroke.val_cloned()); + gc.tags + .text_attrs + .push_overline(gc.options, loc, stroke) + .at(elem.span())?; + return Ok(()); + } else if let Some(strike) = elem.to_packed::() { + let loc = elem.location().unwrap(); + let stroke = deco_stroke(strike.stroke.val_cloned()); + gc.tags + .text_attrs + .push_strike(gc.options, loc, stroke) + .at(elem.span())?; + 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, @@ -307,6 +356,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 +371,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 +385,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`" ); @@ -543,7 +595,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,36 +688,11 @@ 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() { return TagHandle { surface, started: false }; } else if let Some(StackEntryKind::Table(_)) = gc.tags.stack.last().map(|e| &e.kind) { 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/suite/pdftags/deco.typ b/tests/suite/pdftags/deco.typ new file mode 100644 index 000000000..a4f7db98f --- /dev/null +++ b/tests/suite/pdftags/deco.typ @@ -0,0 +1,7 @@ +--- deco-tags-underline pdftags --- +#show: underline.with(stroke: red) + +red underlined text +red underlined text + +red underlined text