Files
wordcraft/crates/pdf/src/export.rs
T
a2f387567d Printing: the print job's PDF leaves out link annotations, so documents with links print (#4734) (#4817)
* #4734: the print job's PDF leaves out link annotations

craft-print refuses to fit a page with annotations to the paper ("flatten page annotations
before using shrink-to-fit"), and the desktop app always prints with ShrinkToFit, so any
document with a hyperlink or a TOC (its entries are links) failed to print. Links print
nothing; `PdfOptions::links` (default on) lets the print job leave them out. Exported PDFs
keep them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4734: review — the print note is in every desktop column; gaps and progress log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4734: doc wording: the print PDF omits link annotations; hardware still to validate

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #4734: only the printer's PDF drops links; Save these pages as PDF keeps them

Session::print_job_pdf is what ui.printSubmit sends to the spooler, without
link annotations; Session::print_pdf (file.printPdf) keeps them again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-31 10:26:28 -04:00

171 lines
6.9 KiB
Rust

use super::exporter::Exporter;
use super::metadata::metadata;
use super::options::{PdfError, PdfOptions};
use super::paint::{clamp_side, ok};
use super::{
DocLayout, Document, HashMap, HashSet, KrillaError, LayoutCache, LayoutOptions, Page, PageSettings, Size, Transform, WriteError, Writer, krilla,
layout, write,
};
/// Lay out `doc` and write it as PDF. Without markup the layout is the final text: tracked
/// deletions are left out, as in Word's "No Markup" view. With `comments` each page carries the
/// markup area with the comment balloons.
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
write::guard(|| {
let lo = LayoutOptions { hide_deleted: !opts.include_markup, comment_balloons: opts.comments, numeral: opts.numeral, ..Default::default() };
let lay = layout(doc, &mut LayoutCache::new(), &lo);
export_layout_inner(doc, &lay, opts)
})
}
/// Write an existing layout of `doc` as PDF.
pub fn export_layout(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
write::guard(|| export_layout_inner(doc, lay, opts))
}
fn export_layout_inner(doc: &Document, lay: &DocLayout, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
let pages: Vec<usize> = match &opts.pages {
Some(v) => {
if let Some(bad) = v.iter().find(|p| **p >= lay.pages.len()) {
return Err(PdfError::BadPage(bad.saturating_add(1)));
}
v.clone()
}
None => (0..lay.pages.len()).collect(),
};
if pages.is_empty() {
return Err(PdfError::NoPages);
}
// krilla subsets fonts only when the document is finished, so one font it can't subset fails
// the whole export. Draw that font's glyphs as outlines instead and write the document again.
let mut outlined = HashSet::new();
loop {
match write_attempt(doc, lay, opts, &pages, &outlined)? {
Attempt::Done(bytes) => return Ok(bytes),
Attempt::BadFont(id, msg) if outlined.len() < MAX_OUTLINED_FONTS && outlined.insert(id) => {
log::warn!("PDF: {msg}; its text is drawn as outlines");
}
Attempt::BadFont(_, msg) => return Err(PdfError::Write(msg)),
}
}
}
/// How many unsubsettable fonts we fall back to outlines for before giving up.
const MAX_OUTLINED_FONTS: usize = 32;
/// One try at writing the PDF.
enum Attempt {
Done(Vec<u8>),
/// The face with this id could not be embedded (and why).
BadFont(u32, String),
}
fn write_attempt(doc: &Document, lay: &DocLayout, opts: &PdfOptions, pages: &[usize], outlined: &HashSet<u32>) -> Result<Attempt, PdfError> {
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, enable_tagging: opts.tagged, ..Default::default() };
let language = doc.styles.default_chr.lang.clone().filter(|l| !l.is_empty() && l.is_ascii());
let mut writer = Writer::new_with_language(settings, metadata(doc, opts), language);
let pdf = writer.document_mut();
let mut ex = Exporter {
doc,
lay,
opts,
out_index: pages.iter().enumerate().map(|(o, p)| (*p, o)).collect(),
fonts: HashMap::new(),
font_error: None,
outlined,
cmaps: HashMap::new(),
images: HashMap::new(),
vectors: HashMap::new(),
tags: Vec::new(),
tag_index: HashMap::new(),
links: Vec::new(),
bookmarks: std::cell::OnceCell::new(),
};
if let Some(o) = ex.outline(pages) {
pdf.set_outline(o);
}
for &pi in pages {
let Some(page) = lay.pages.get(pi) else { return Err(PdfError::BadPage(pi.saturating_add(1))) };
let fit = Fit::of(page, opts.paper);
let size = Size::from_wh(clamp_side(fit.paper_w), clamp_side(fit.paper_h)).ok_or(PdfError::NoPages)?;
let mut kp = pdf.start_page_with(PageSettings::new(size));
let mut s = kp.surface();
ex.links.clear();
// A page with a markup area is scaled down to fit its paper with the area beside it; a
// print on other paper is scaled to it and centred.
let scaled = fit.k != 1.0 || fit.dx != 0.0 || fit.dy != 0.0;
if scaled {
s.push_transform(&Transform::from_row(fit.k, 0.0, 0.0, fit.k, fit.dx, fit.dy));
}
// `page_w` is the document page's width: the page colour and watermark stay off the markup area.
ex.page(&mut s, page, clamp_side(fit.page_w), clamp_side(page.h));
if scaled {
s.pop();
}
s.finish();
if opts.links {
for a in ex.annotations(fit) {
kp.add_annotation(a);
}
}
kp.finish();
}
if opts.tagged {
pdf.set_tag_tree(ex.tag_tree());
}
if let Some(error) = ex.font_error {
return Err(error);
}
match writer.finish() {
Ok(bytes) => Ok(Attempt::Done(bytes)),
Err(WriteError::Serialization(error)) => match *error {
KrillaError::Font(font, msg) => {
let bad = ex.fonts.iter().find(|(_, (_, f))| f.as_ref() == Some(&font));
match bad {
Some((id, (face, _))) => Ok(Attempt::BadFont(*id, format!("font {} {} could not be embedded ({msg})", face.family, face.style))),
None => Err(PdfError::Write(format!("a font could not be embedded ({msg})"))),
}
}
error => Err(PdfError::Write(format!("{error:?}"))),
},
Err(error) => Err(error.into()),
}
}
/// How a layout page maps onto its PDF page: a page with a markup area (Print Markup) is scaled
/// by `k` to its paper width and centred vertically (`dy`). With [`PdfOptions::paper`] that
/// sheet is then scaled to the chosen paper and centred on it (`dx`, `dy`). Any other page is
/// drawn as it is.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Fit {
/// The PDF page.
paper_w: f32,
paper_h: f32,
/// The document page's width in layout points (without the markup area).
page_w: f32,
k: f32,
dx: f32,
dy: f32,
}
impl Fit {
pub(crate) fn of(page: &Page, paper: Option<(f32, f32)>) -> Fit {
let paper_w = page.w - page.markup;
let own = if page.markup > 0.0 && ok(page.markup) && ok(page.w) && paper_w > 1.0 {
let k = paper_w / page.w;
Fit { paper_w, paper_h: page.h, page_w: paper_w, k, dx: 0.0, dy: page.h * (1.0 - k) / 2.0 }
} else {
Fit { paper_w: page.w, paper_h: page.h, page_w: page.w, k: 1.0, dx: 0.0, dy: 0.0 }
};
let Some((w, h)) = paper else { return own };
let f = wordcraft_geom::fit_to_paper(own.paper_w, own.paper_h, w, h);
Fit { paper_w: f.paper_w, paper_h: f.paper_h, page_w: own.page_w, k: own.k * f.k, dx: f.dx, dy: own.dy * f.k + f.dy }
}
/// A layout point on the PDF page.
pub(crate) fn point(self, x: f32, y: f32) -> (f32, f32) {
(x * self.k + self.dx, y * self.k + self.dy)
}
}