feat: [WIP] text decoration attributes

This commit is contained in:
Tobias Schmitz 2025-08-01 16:02:58 +02:00
parent df6a4ba7f4
commit b2d9bd2fb4
No known key found for this signature in database
7 changed files with 353 additions and 82 deletions

View File

@ -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);

View File

@ -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 {

View File

@ -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());

View File

@ -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<Lang>,
/// 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<TagNode>,
tree: Vec<TagNode>,
}
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<Item = TagNode>) {
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::<Vec<_>>();
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<Lang> {
// 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>, node: TagNode) {
match node {
TagNode::Group(TagGroup { tag, nodes }) => {
let children = nodes
.into_iter()
.map(|node| self.resolve_node(node))
.collect::<Vec<_>>();
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<LineHeight>,
baseline_shift: Option<f32>,
/// 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<NaiveRgbColor>,
pub thickness: Option<Length>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ResolvedTextAttrs {
lineheight: Option<LineHeight>,
baseline_shift: Option<f32>,
deco: Option<ResolvedTextDeco>,
}
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<NaiveRgbColor>,
thickness: Option<f32>,
}
#[derive(Debug)]
pub struct TagStack {
items: Vec<StackEntry>,

View File

@ -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<Identifier>),
}
impl TagNode {
@ -108,7 +114,7 @@ pub fn handle_start(
return Ok(());
}
let mut tag: TagKind = if let Some(tag) = elem.to_packed::<PdfMarkerTag>() {
let tag = if let Some(tag) = elem.to_packed::<PdfMarkerTag>() {
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::<UnderlineElem>() {
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::<OverlineElem>() {
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::<StrikeElem>() {
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<Stroke>) -> 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 {
if gc.options.is_pdf_ua() {
let validator = gc.options.standards.config.validator();
if is_pdf_ua {
let ua1 = validator.as_str();
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) {

View File

@ -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())?;

View File

@ -0,0 +1,7 @@
--- deco-tags-underline pdftags ---
#show: underline.with(stroke: red)
red underlined text
red underlined text
red underlined text