mirror of
https://github.com/typst/typst
synced 2025-06-08 13:16:24 +08:00
Add full support for links
This commit is contained in:
parent
aa57554176
commit
b0339cacc2
@ -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());
|
||||||
|
@ -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};
|
||||||
|
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
@ -157,4 +156,4 @@ pub(crate) fn font_to_str(font: &Font) -> String {
|
|||||||
let font_family = &font.info().family;
|
let font_family = &font.info().family;
|
||||||
let font_variant = font.info().variant;
|
let font_variant = font.info().variant;
|
||||||
format!("{} ({:?})", font_family, font_variant)
|
format!("{} ({:?})", font_family, font_variant)
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user