mirror of
https://github.com/typst/typst
synced 2025-06-08 13:16:24 +08:00
more
This commit is contained in:
parent
47c7af2e86
commit
4cd6a123ae
@ -146,6 +146,7 @@ impl CustomImage for PdfImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn color_space(&self) -> ImageColorspace {
|
fn color_space(&self) -> ImageColorspace {
|
||||||
|
// Remember that we convert all images to either RGB or luma.
|
||||||
if self.raster.dynamic().color().has_color() {
|
if self.raster.dynamic().color().has_color() {
|
||||||
ImageColorspace::Rgb
|
ImageColorspace::Rgb
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,12 +1,5 @@
|
|||||||
use crate::image::handle_image;
|
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||||
use crate::link::handle_link;
|
|
||||||
use crate::metadata::build_metadata;
|
|
||||||
use crate::outline::build_outline;
|
|
||||||
use crate::page::PageLabelExt;
|
|
||||||
use crate::shape::handle_shape;
|
|
||||||
use crate::text::handle_text;
|
|
||||||
use crate::util::{convert_path, display_font, AbsExt, TransformExt};
|
|
||||||
use crate::PdfOptions;
|
|
||||||
use krilla::destination::{NamedDestination, XyzDestination};
|
use krilla::destination::{NamedDestination, XyzDestination};
|
||||||
use krilla::error::KrillaError;
|
use krilla::error::KrillaError;
|
||||||
use krilla::page::PageLabel;
|
use krilla::page::PageLabel;
|
||||||
@ -15,7 +8,7 @@ use krilla::surface::Surface;
|
|||||||
use krilla::validation::ValidationError;
|
use krilla::validation::ValidationError;
|
||||||
use krilla::version::PdfVersion;
|
use krilla::version::PdfVersion;
|
||||||
use krilla::{Document, PageSettings, SerializeSettings};
|
use krilla::{Document, PageSettings, SerializeSettings};
|
||||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
use krilla::annotation::Annotation;
|
||||||
use typst_library::diag::{bail, SourceResult};
|
use typst_library::diag::{bail, SourceResult};
|
||||||
use typst_library::foundations::NativeElement;
|
use typst_library::foundations::NativeElement;
|
||||||
use typst_library::introspection::Location;
|
use typst_library::introspection::Location;
|
||||||
@ -27,6 +20,104 @@ use typst_library::text::{Font, Lang};
|
|||||||
use typst_library::visualize::{Geometry, Paint};
|
use typst_library::visualize::{Geometry, Paint};
|
||||||
use typst_syntax::Span;
|
use typst_syntax::Span;
|
||||||
|
|
||||||
|
use crate::image::handle_image;
|
||||||
|
use crate::link::handle_link;
|
||||||
|
use crate::metadata::build_metadata;
|
||||||
|
use crate::outline::build_outline;
|
||||||
|
use crate::page::PageLabelExt;
|
||||||
|
use crate::shape::handle_shape;
|
||||||
|
use crate::text::handle_text;
|
||||||
|
use crate::util::{convert_path, display_font, AbsExt, TransformExt};
|
||||||
|
use crate::PdfOptions;
|
||||||
|
|
||||||
|
#[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 {
|
||||||
|
compress_content_streams: true,
|
||||||
|
no_device_cs: true,
|
||||||
|
ascii_compatible: false,
|
||||||
|
xmp_metadata: true,
|
||||||
|
cmyk_profile: None,
|
||||||
|
validator: options.validator,
|
||||||
|
enable_tagging: false,
|
||||||
|
pdf_version: version,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut document = Document::new_with(settings);
|
||||||
|
let mut gc = GlobalContext::new(&typst_document, options, collect_named_destinations(typst_document, options));
|
||||||
|
|
||||||
|
convert_pages(&mut gc, &mut document)?;
|
||||||
|
|
||||||
|
document.set_outline(build_outline(&gc));
|
||||||
|
document.set_metadata(build_metadata(&gc));
|
||||||
|
|
||||||
|
finish(document, gc)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_pages(gc: &mut GlobalContext, document: &mut Document) -> SourceResult<()> {
|
||||||
|
let mut skipped_pages = 0;
|
||||||
|
|
||||||
|
for (i, typst_page) in gc.document.pages.iter().enumerate() {
|
||||||
|
if gc.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());
|
||||||
|
|
||||||
|
handle_frame(
|
||||||
|
&mut fc,
|
||||||
|
&typst_page.frame,
|
||||||
|
typst_page.fill_or_transparent(),
|
||||||
|
&mut surface,
|
||||||
|
gc,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
surface.finish();
|
||||||
|
|
||||||
|
for annotation in fc.annotations {
|
||||||
|
page.add_annotation(annotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// A state allowing us to keep track of transforms and container sizes,
|
/// A state allowing us to keep track of transforms and container sizes,
|
||||||
/// which is mainly needed to resolve gradients and patterns correctly.
|
/// which is mainly needed to resolve gradients and patterns correctly.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -71,9 +162,10 @@ impl State {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Context needed for converting a single frame.
|
||||||
pub(crate) struct FrameContext {
|
pub(crate) struct FrameContext {
|
||||||
states: Vec<State>,
|
states: Vec<State>,
|
||||||
pub(crate) annotations: Vec<krilla::annotation::Annotation>,
|
annotations: Vec<Annotation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FrameContext {
|
impl FrameContext {
|
||||||
@ -99,16 +191,21 @@ impl FrameContext {
|
|||||||
pub(crate) fn state_mut(&mut self) -> &mut State {
|
pub(crate) fn state_mut(&mut self) -> &mut State {
|
||||||
self.states.last_mut().unwrap()
|
self.states.last_mut().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn push_annotation(&mut self, annotation: Annotation) {
|
||||||
|
self.annotations.push(annotation);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Globally needed context for converting a typst document.
|
||||||
pub(crate) struct GlobalContext<'a> {
|
pub(crate) struct GlobalContext<'a> {
|
||||||
/// Cache the conversion between krilla and Typst fonts (forward and backward).
|
/// Cache the conversion between krilla and Typst fonts (forward and backward).
|
||||||
pub(crate) fonts_forward: HashMap<Font, krilla::font::Font>,
|
pub(crate) fonts_forward: HashMap<Font, krilla::font::Font>,
|
||||||
pub(crate) fonts_backward: HashMap<krilla::font::Font, Font>,
|
pub(crate) fonts_backward: HashMap<krilla::font::Font, Font>,
|
||||||
|
/// Mapping between images and their span.
|
||||||
// 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.
|
||||||
/// Mapping between images and their span.
|
|
||||||
pub(crate) image_spans: HashMap<krilla::image::Image, Span>,
|
pub(crate) image_spans: HashMap<krilla::image::Image, Span>,
|
||||||
pub(crate) document: &'a PagedDocument,
|
pub(crate) document: &'a PagedDocument,
|
||||||
pub(crate) options: &'a PdfOptions<'a>,
|
pub(crate) options: &'a PdfOptions<'a>,
|
||||||
@ -119,7 +216,7 @@ pub(crate) struct GlobalContext<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> GlobalContext<'a> {
|
impl<'a> GlobalContext<'a> {
|
||||||
pub fn new(
|
pub(crate) fn new(
|
||||||
document: &'a PagedDocument,
|
document: &'a PagedDocument,
|
||||||
options: &'a PdfOptions,
|
options: &'a PdfOptions,
|
||||||
loc_to_named: HashMap<Location, NamedDestination>,
|
loc_to_named: HashMap<Location, NamedDestination>,
|
||||||
@ -143,129 +240,6 @@ impl<'a> GlobalContext<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Change rustybuzz cluster behavior so it works with ActualText
|
|
||||||
|
|
||||||
#[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 {
|
|
||||||
compress_content_streams: true,
|
|
||||||
no_device_cs: true,
|
|
||||||
ascii_compatible: false,
|
|
||||||
xmp_metadata: true,
|
|
||||||
cmyk_profile: None,
|
|
||||||
validator: options.validator,
|
|
||||||
enable_tagging: false,
|
|
||||||
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 = Document::new_with(settings);
|
|
||||||
let mut gc = GlobalContext::new(&typst_document, options, locs_to_names);
|
|
||||||
|
|
||||||
let mut skipped_pages = 0;
|
|
||||||
|
|
||||||
for (i, typst_page) in typst_document.pages.iter().enumerate() {
|
|
||||||
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());
|
|
||||||
handle_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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.set_outline(build_outline(&gc));
|
|
||||||
document.set_metadata(build_metadata(&gc));
|
|
||||||
|
|
||||||
finish(document, gc)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn handle_frame(
|
pub(crate) fn handle_frame(
|
||||||
fc: &mut FrameContext,
|
fc: &mut FrameContext,
|
||||||
frame: &Frame,
|
frame: &Frame,
|
||||||
@ -443,12 +417,12 @@ fn finish(document: Document, gc: GlobalContext) -> SourceResult<Vec<u8>> {
|
|||||||
bail!(Span::detached(), "{prefix} missing document language";
|
bail!(Span::detached(), "{prefix} missing document language";
|
||||||
hint: "set the language of the document");
|
hint: "set the language of the document");
|
||||||
}
|
}
|
||||||
// Needs to be set by Typst.
|
// Needs to be set by typst-pdf.
|
||||||
ValidationError::MissingHeadingTitle => {
|
ValidationError::MissingHeadingTitle => {
|
||||||
bail!(Span::detached(), "{prefix} missing heading title";
|
bail!(Span::detached(), "{prefix} missing heading title";
|
||||||
hint: "please report this as a bug");
|
hint: "please report this as a bug");
|
||||||
}
|
}
|
||||||
// Needs to be set by Typst.
|
// Needs to be set by typst-pdf.
|
||||||
ValidationError::MissingDocumentOutline => {
|
ValidationError::MissingDocumentOutline => {
|
||||||
bail!(Span::detached(), "{prefix} missing document outline";
|
bail!(Span::detached(), "{prefix} missing document outline";
|
||||||
hint: "please report this as a bug");
|
hint: "please report this as a bug");
|
||||||
@ -463,8 +437,52 @@ fn finish(document: Document, gc: GlobalContext) -> SourceResult<Vec<u8>> {
|
|||||||
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");
|
||||||
}
|
}
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_named_destinations(document: &PagedDocument, options: &PdfOptions) -> HashMap<Location, NamedDestination> {
|
||||||
|
let mut locs_to_names = HashMap::new();
|
||||||
|
|
||||||
|
// Find all headings that have a label and are the first among other
|
||||||
|
// headings with the same label.
|
||||||
|
let matches: Vec<_> = {
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
document
|
||||||
|
.introspector
|
||||||
|
.query(&HeadingElem::elem().select())
|
||||||
|
.iter()
|
||||||
|
.filter_map(|elem| elem.location().zip(elem.label()))
|
||||||
|
.filter(|&(_, label)| seen.insert(label))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
for (loc, label) in matches {
|
||||||
|
let pos = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locs_to_names
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_version(options: &PdfOptions) -> SourceResult<PdfVersion> {
|
fn get_version(options: &PdfOptions) -> SourceResult<PdfVersion> {
|
||||||
|
@ -15,6 +15,9 @@ 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};
|
||||||
|
|
||||||
|
pub use ::krilla::validation::Validator;
|
||||||
|
pub use ::krilla::version::PdfVersion;
|
||||||
|
|
||||||
/// Export a document into a PDF file.
|
/// Export a document into a PDF file.
|
||||||
///
|
///
|
||||||
/// Returns the raw bytes making up the PDF file.
|
/// Returns the raw bytes making up the PDF file.
|
||||||
@ -23,9 +26,6 @@ pub fn pdf(document: &PagedDocument, options: &PdfOptions) -> SourceResult<Vec<u
|
|||||||
krilla::pdf(document, options)
|
krilla::pdf(document, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub use ::krilla::validation::Validator;
|
|
||||||
pub use ::krilla::version::PdfVersion;
|
|
||||||
|
|
||||||
/// Settings for PDF export.
|
/// Settings for PDF export.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct PdfOptions<'a> {
|
pub struct PdfOptions<'a> {
|
||||||
|
@ -44,7 +44,7 @@ pub(crate) fn handle_link(
|
|||||||
|
|
||||||
let pos = match dest {
|
let pos = match dest {
|
||||||
Destination::Url(u) => {
|
Destination::Url(u) => {
|
||||||
fc.annotations.push(
|
fc.push_annotation(
|
||||||
LinkAnnotation::new(
|
LinkAnnotation::new(
|
||||||
rect,
|
rect,
|
||||||
Target::Action(Action::Link(LinkAction::new(u.to_string()))),
|
Target::Action(Action::Link(LinkAction::new(u.to_string()))),
|
||||||
@ -58,7 +58,7 @@ pub(crate) fn handle_link(
|
|||||||
if let Some(nd) = gc.loc_to_named.get(loc) {
|
if let Some(nd) = gc.loc_to_named.get(loc) {
|
||||||
// If a named destination has been registered, it's already guaranteed to
|
// If a named destination has been registered, it's already guaranteed to
|
||||||
// not point to an excluded page.
|
// not point to an excluded page.
|
||||||
fc.annotations.push(
|
fc.push_annotation(
|
||||||
LinkAnnotation::new(
|
LinkAnnotation::new(
|
||||||
rect,
|
rect,
|
||||||
Target::Destination(krilla::destination::Destination::Named(
|
Target::Destination(krilla::destination::Destination::Named(
|
||||||
@ -76,7 +76,7 @@ pub(crate) fn handle_link(
|
|||||||
|
|
||||||
let page_index = pos.page.get() - 1;
|
let page_index = pos.page.get() - 1;
|
||||||
if !gc.page_excluded(page_index) {
|
if !gc.page_excluded(page_index) {
|
||||||
fc.annotations.push(
|
fc.push_annotation(
|
||||||
LinkAnnotation::new(
|
LinkAnnotation::new(
|
||||||
rect,
|
rect,
|
||||||
Target::Destination(krilla::destination::Destination::Xyz(
|
Target::Destination(krilla::destination::Destination::Xyz(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user