Add full support for links

This commit is contained in:
Laurenz Stampfl 2024-12-16 22:52:03 +01:00
parent aa57554176
commit b0339cacc2
4 changed files with 202 additions and 69 deletions

View File

@ -1,26 +1,29 @@
use crate::util::{font_to_str, AbsExt, PointExt, SizeExt, TransformExt}; use crate::util::{font_to_str, AbsExt, PageLabelExt, PointExt, SizeExt, TransformExt};
use crate::{paint, PdfOptions}; use crate::{paint, PdfOptions};
use bytemuck::TransparentWrapper; use bytemuck::TransparentWrapper;
use ecow::EcoString; use ecow::EcoString;
use krilla::action::{Action, LinkAction}; use krilla::action::{Action, LinkAction};
use krilla::annotation::{LinkAnnotation, Target}; use krilla::annotation::{LinkAnnotation, Target};
use krilla::destination::XyzDestination; use krilla::destination::{NamedDestination, XyzDestination};
use krilla::error::KrillaError;
use krilla::font::{GlyphId, GlyphUnits}; use krilla::font::{GlyphId, GlyphUnits};
use krilla::geom::Rect;
use krilla::page::PageLabel;
use krilla::path::PathBuilder; use krilla::path::PathBuilder;
use krilla::surface::Surface; use krilla::surface::Surface;
use krilla::validation::ValidationError;
use krilla::version::PdfVersion;
use krilla::{PageSettings, SerializeSettings, SvgSettings}; use krilla::{PageSettings, SerializeSettings, SvgSettings};
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::ops::Range; use std::ops::Range;
use std::sync::Arc; use std::sync::Arc;
use krilla::error::KrillaError;
use krilla::geom::Rect;
use krilla::validation::ValidationError;
use typst_library::diag::{bail, SourceResult}; use typst_library::diag::{bail, SourceResult};
use typst_library::foundations::Datetime; use typst_library::foundations::{Datetime, NativeElement};
use typst_library::introspection::Location;
use typst_library::layout::{ use typst_library::layout::{
Abs, Frame, FrameItem, GroupItem, PagedDocument, Point, Size, Transform, Abs, Frame, FrameItem, GroupItem, PagedDocument, Point, Size, Transform,
}; };
use typst_library::model::Destination; use typst_library::model::{Destination, HeadingElem};
use typst_library::text::{Font, Glyph, Lang, TextItem}; use typst_library::text::{Font, Glyph, Lang, TextItem};
use typst_library::visualize::{ use typst_library::visualize::{
FillRule, Geometry, Image, ImageKind, Paint, Path, PathItem, Shape, FillRule, Geometry, Image, ImageKind, Paint, Path, PathItem, Shape,
@ -151,36 +154,49 @@ impl krilla::font::Glyph for PdfGlyph {
} }
} }
pub struct GlobalContext { pub struct GlobalContext<'a> {
fonts_forward: HashMap<Font, krilla::font::Font>, fonts_forward: HashMap<Font, krilla::font::Font>,
fonts_backward: HashMap<krilla::font::Font, Font>, fonts_backward: HashMap<krilla::font::Font, Font>,
// Note: In theory, the same image can have multiple spans // Note: In theory, the same image can have multiple spans
// if it appears in the document multiple times. We just store the // if it appears in the document multiple times. We just store the
// first appearance, though. // first appearance, though.
image_spans: HashMap<krilla::image::Image, Span>, image_spans: HashMap<krilla::image::Image, Span>,
document: &'a PagedDocument,
options: &'a PdfOptions<'a>,
loc_to_named: HashMap<Location, NamedDestination>,
languages: BTreeMap<Lang, usize>, languages: BTreeMap<Lang, usize>,
} }
impl GlobalContext { impl<'a> GlobalContext<'a> {
pub fn new() -> Self { pub fn new(
document: &'a PagedDocument,
options: &'a PdfOptions,
loc_to_named: HashMap<Location, NamedDestination>,
) -> GlobalContext<'a> {
Self { Self {
fonts_forward: HashMap::new(), fonts_forward: HashMap::new(),
fonts_backward: HashMap::new(), fonts_backward: HashMap::new(),
document,
options,
loc_to_named,
image_spans: HashMap::new(), image_spans: HashMap::new(),
languages: BTreeMap::new(), languages: BTreeMap::new(),
} }
} }
pub(crate) fn page_excluded(&self, page_index: usize) -> bool {
self.options
.page_ranges
.as_ref()
.is_some_and(|ranges| !ranges.includes_page_index(page_index))
}
} }
// TODO: Change rustybuzz cluster behavior so it works with ActualText // TODO: Change rustybuzz cluster behavior so it works with ActualText
#[typst_macros::time(name = "write pdf")] fn get_version(options: &PdfOptions) -> SourceResult<PdfVersion> {
pub fn pdf( match options.pdf_version {
typst_document: &PagedDocument, None => Ok(options.validator.recommended_version()),
options: &PdfOptions,
) -> SourceResult<Vec<u8>> {
let version = match options.pdf_version {
None => options.validator.recommended_version(),
Some(v) => { Some(v) => {
if !options.validator.compatible_with_version(v) { if !options.validator.compatible_with_version(v) {
let v_string = v.as_str(); let v_string = v.as_str();
@ -191,10 +207,18 @@ pub fn pdf(
); );
bail!(Span::detached(), "{v_string} is not compatible with standard {s_string}"; hint: "{h_message}"); bail!(Span::detached(), "{v_string} is not compatible with standard {s_string}"; hint: "{h_message}");
} else { } else {
v Ok(v)
} }
} }
}; }
}
#[typst_macros::time(name = "write pdf")]
pub fn pdf(
typst_document: &PagedDocument,
options: &PdfOptions,
) -> SourceResult<Vec<u8>> {
let version = get_version(options)?;
let settings = SerializeSettings { let settings = SerializeSettings {
compress_content_streams: true, compress_content_streams: true,
@ -207,29 +231,100 @@ pub fn pdf(
pdf_version: version, pdf_version: version,
}; };
let mut locs_to_names = HashMap::new();
let mut seen = HashSet::new();
// Find all headings that have a label and are the first among other
// headings with the same label.
let mut matches: Vec<_> = typst_document
.introspector
.query(&HeadingElem::elem().select())
.iter()
.filter_map(|elem| elem.location().zip(elem.label()))
.filter(|&(_, label)| seen.insert(label))
.collect();
// Named destinations must be sorted by key.
matches.sort_by_key(|&(_, label)| label.resolve());
for (loc, label) in matches {
let pos = typst_document.introspector.position(loc);
let index = pos.page.get() - 1;
// We are subtracting 10 because the position of links e.g. to headings is always at the
// baseline and if you link directly to it, the text will not be visible
// because it is right above.
let y = (pos.point.y - Abs::pt(10.0)).max(Abs::zero());
// Only add named destination if page belonging to the position is exported.
if options
.page_ranges
.as_ref()
.is_some_and(|ranges| !ranges.includes_page_index(index))
{
let named = NamedDestination::new(
label.resolve().to_string(),
XyzDestination::new(
index,
krilla::geom::Point::from_xy(pos.point.x.to_f32(), y.to_f32()),
),
);
locs_to_names.insert(loc, named);
}
}
let mut document = krilla::Document::new_with(settings); let mut document = krilla::Document::new_with(settings);
let mut gc = GlobalContext::new(); let mut gc = GlobalContext::new(&typst_document, options, locs_to_names);
for typst_page in &typst_document.pages { let mut skipped_pages = 0;
let settings = PageSettings::new(
typst_page.frame.width().to_f32(),
typst_page.frame.height().to_f32(),
);
let mut page = document.start_page_with(settings);
let mut surface = page.surface();
let mut fc = FrameContext::new(typst_page.frame.size());
// println!("{:?}", &typst_page.frame);
process_frame(
&mut fc,
&typst_page.frame,
typst_page.fill_or_transparent(),
&mut surface,
&mut gc,
)?;
surface.finish();
for annotation in fc.annotations { for (i, typst_page) in typst_document.pages.iter().enumerate() {
page.add_annotation(annotation); if options
.page_ranges
.as_ref()
.is_some_and(|ranges| !ranges.includes_page_index(i))
{
// Don't export this page.
skipped_pages += 1;
continue;
} else {
let mut settings = PageSettings::new(
typst_page.frame.width().to_f32(),
typst_page.frame.height().to_f32(),
);
if let Some(label) = typst_page
.numbering
.as_ref()
.and_then(|num| PageLabel::generate(num, typst_page.number))
.or_else(|| {
// When some pages were ignored from export, we show a page label with
// the correct real (not logical) page number.
// This is for consistency with normal output when pages have no numbering
// and all are exported: the final PDF page numbers always correspond to
// the real (not logical) page numbers. Here, the final PDF page number
// will differ, but we can at least use labels to indicate what was
// the corresponding real page number in the Typst document.
(skipped_pages > 0).then(|| PageLabel::arabic(i + 1))
})
{
settings = settings.with_page_label(label);
}
let mut page = document.start_page_with(settings);
let mut surface = page.surface();
let mut fc = FrameContext::new(typst_page.frame.size());
process_frame(
&mut fc,
&typst_page.frame,
typst_page.fill_or_transparent(),
&mut surface,
&mut gc,
)?;
surface.finish();
for annotation in fc.annotations {
page.add_annotation(annotation);
}
} }
} }
@ -296,7 +391,10 @@ pub fn pdf(
} }
KrillaError::ValidationError(ve) => { KrillaError::ValidationError(ve) => {
// We can only produce 1 error, so just take the first one. // We can only produce 1 error, so just take the first one.
let prefix = format!("validated export for {} failed:", options.validator.as_str()); let prefix = format!(
"validated export for {} failed:",
options.validator.as_str()
);
match &ve[0] { match &ve[0] {
ValidationError::TooLongString => { ValidationError::TooLongString => {
bail!(Span::detached(), "{prefix} a PDF string longer than 32767 characters"; bail!(Span::detached(), "{prefix} a PDF string longer than 32767 characters";
@ -391,7 +489,7 @@ pub fn pdf(
let span = gc.image_spans.get(&i).unwrap(); let span = gc.image_spans.get(&i).unwrap();
bail!(*span, "failed to process image"); bail!(*span, "failed to process image");
} }
} },
} }
} }
@ -456,7 +554,7 @@ pub fn process_frame(
FrameItem::Image(image, size, span) => { FrameItem::Image(image, size, span) => {
handle_image(gc, fc, image, *size, surface, *span)? handle_image(gc, fc, image, *size, surface, *span)?
} }
FrameItem::Link(d, s) => write_link(fc, d, *s), FrameItem::Link(d, s) => write_link(fc, gc, d, *s),
FrameItem::Tag(_) => {} FrameItem::Tag(_) => {}
} }
@ -469,7 +567,12 @@ pub fn process_frame(
} }
/// Save a link for later writing in the annotations dictionary. /// Save a link for later writing in the annotations dictionary.
fn write_link(fc: &mut FrameContext, dest: &Destination, size: Size) { fn write_link(
fc: &mut FrameContext,
gc: &mut GlobalContext,
dest: &Destination,
size: Size,
) {
let mut min_x = Abs::inf(); let mut min_x = Abs::inf();
let mut min_y = Abs::inf(); let mut min_y = Abs::inf();
let mut max_x = -Abs::inf(); let mut max_x = -Abs::inf();
@ -498,21 +601,48 @@ fn write_link(fc: &mut FrameContext, dest: &Destination, size: Size) {
let rect = Rect::from_ltrb(x1, y1, x2, y2).unwrap(); let rect = Rect::from_ltrb(x1, y1, x2, y2).unwrap();
let target = match dest { let pos = match dest {
Destination::Url(u) => { Destination::Url(u) => {
Target::Action(Action::Link(LinkAction::new(u.to_string()))) fc.annotations.push(
LinkAnnotation::new(
rect,
Target::Action(Action::Link(LinkAction::new(u.to_string()))),
)
.into(),
);
return;
} }
Destination::Position(p) => { Destination::Position(p) => *p,
// TODO: Ignore non-exported destinations Destination::Location(loc) => {
Target::Destination(krilla::destination::Destination::Xyz( if let Some(named_dest) = gc.loc_to_named.get(loc) {
XyzDestination::new(p.page.get() - 1, p.point.as_krilla()), fc.annotations.push(
)) LinkAnnotation::new(
rect,
Target::Destination(krilla::destination::Destination::Named(
named_dest.clone(),
)),
)
.into(),
);
return;
} else {
gc.document.introspector.position(*loc)
}
} }
// TODO: Implement
Destination::Location(_) => return,
}; };
fc.annotations.push(LinkAnnotation::new(rect, target).into()); let page_index = pos.page.get() - 1;
if !gc.page_excluded(page_index) {
fc.annotations.push(
LinkAnnotation::new(
rect,
Target::Destination(krilla::destination::Destination::Xyz(
XyzDestination::new(page_index, pos.point.as_krilla()),
)),
)
.into(),
);
}
} }
pub fn handle_group( pub fn handle_group(
@ -559,13 +689,17 @@ pub fn handle_text(
let krilla_font = if let Some(font) = gc.fonts_forward.get(&typst_font) { let krilla_font = if let Some(font) = gc.fonts_forward.get(&typst_font) {
font.clone() font.clone()
} else { } else {
let font = match krilla::font::Font::new(Arc::new(typst_font.data().clone()), typst_font.index(), true) { let font = match krilla::font::Font::new(
Arc::new(typst_font.data().clone()),
typst_font.index(),
true,
) {
None => { None => {
let font_str = font_to_str(&typst_font); let font_str = font_to_str(&typst_font);
bail!(Span::detached(), "failed to process font {font_str}"); bail!(Span::detached(), "failed to process font {font_str}");
} }
Some(f) => f Some(f) => f,
}; };
gc.fonts_forward.insert(typst_font.clone(), font.clone()); gc.fonts_forward.insert(typst_font.clone(), font.clone());

View File

@ -1,12 +1,11 @@
//! Exporting of Typst documents into PDFs. //! Exporting of Typst documents into PDFs.
mod image; mod image;
mod krilla; mod krilla;
mod paint; mod paint;
mod util; mod util;
use typst_library::diag::{SourceResult}; use typst_library::diag::SourceResult;
use typst_library::foundations::{Datetime, Smart}; use typst_library::foundations::{Datetime, Smart};
use typst_library::layout::{PageRanges, PagedDocument}; use typst_library::layout::{PageRanges, PagedDocument};

View File

@ -78,17 +78,18 @@ fn paint(
let (p, alpha) = match c.space() { let (p, alpha) = match c.space() {
ColorSpace::D65Gray => { ColorSpace::D65Gray => {
let components = c.to_vec4_u8(); let components = c.to_vec4_u8();
( (krilla::color::luma::Color::new(components[0]).into(), components[3])
krilla::color::luma::Color::new(components[0])
.into(),
components[3],
)
} }
ColorSpace::Cmyk => { ColorSpace::Cmyk => {
let components = c.to_vec4_u8(); let components = c.to_vec4_u8();
( (
krilla::color::cmyk::Color::new(components[0], components[1], components[2], components[3]) krilla::color::cmyk::Color::new(
.into(), components[0],
components[1],
components[2],
components[3],
)
.into(),
// Typst doesn't support alpha on CMYK colors. // Typst doesn't support alpha on CMYK colors.
255, 255,
) )

View File

@ -1,13 +1,12 @@
//! Convert basic primitive types from typst to krilla. //! Convert basic primitive types from typst to krilla.
use std::num::NonZeroUsize;
use krilla::page::{NumberingStyle, PageLabel}; use krilla::page::{NumberingStyle, PageLabel};
use std::num::NonZeroUsize;
use typst_library::layout::{Abs, Point, Size, Transform}; use typst_library::layout::{Abs, Point, Size, Transform};
use typst_library::model::Numbering; use typst_library::model::Numbering;
use typst_library::text::Font; use typst_library::text::Font;
use typst_library::visualize::{FillRule, LineCap, LineJoin}; use typst_library::visualize::{FillRule, LineCap, LineJoin};
pub(crate) trait SizeExt { pub(crate) trait SizeExt {
fn as_krilla(&self) -> krilla::geom::Size; fn as_krilla(&self) -> krilla::geom::Size;
} }