Files
ElyOffice/apps/ely-pdf/src/output.rs
T

374 lines
17 KiB
Rust

//! Export, print, and the repaired copy's report (PDF-13, PDF-14).
use std::{io, path::PathBuf};
use ely_gpui_component::{
buttons::{Button, ButtonVariant, SegmentedControl},
data_display::DescriptionList,
feedback::Banner,
forms::{Checkbox, NumberInput},
overlays::Dialog as Card,
primitives::Severity,
theme::ControlSize,
};
use ely_office::{Chrome as _, Closing as _, Saving as _, Stopping as _, Tabs as _, in_window};
use ely_pdf_engine::Optimizing;
use ely_ui::commands::{Export, Print};
use gpui::{AnyElement, App, Context, IntoElement, ParentElement as _, Styled as _, TaskExt as _, Window, div};
use crate::view::{PdfView, Reported};
use crate::viewer::Viewer;
impl PdfView {
/// A draw met damage; read what MuPDF has said.
pub fn found_damage(&mut self, cx: &mut Context<Self>) {
let doc = self.doc;
self.off(
move |worker| worker.repair_report(doc),
|view, read, cx| match read {
Ok(report) => {
(view.repair, view.damage_seen) = (report, true);
cx.emit(Reported);
}
Err(error) => view.fail("reading the damage report", &error, cx),
},
cx,
);
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Images,
Text,
Optimized,
}
/// What the export dialog asks for.
pub struct Exporting {
tab: u64,
pub kind: Kind,
dpi: u32,
/// Every page, or the picked ones.
all: bool,
pub how: Optimizing,
}
/// `stem-N.png` for each page, numbers padded alike.
pub fn image_names(stem: &str, pages: &[usize], count: usize) -> Vec<String> {
let width = count.to_string().len();
pages.iter().map(|page| format!("{stem}-{:0width$}.png", page + 1)).collect()
}
fn stem(name: &str) -> String {
name.rsplit_once('.').map_or(name, |(stem, _)| stem).to_string()
}
impl Viewer {
pub fn on_export(&mut self, _: &Export, _: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
let how = Optimizing { compress: true, from: 300, to: 150 };
self.exporting = Some(Exporting { tab, kind: Kind::Images, dpi: 150, all: true, how });
cx.notify();
}
pub fn on_print(&mut self, _: &Print, _: &mut Window, cx: &mut Context<Self>) {
let Some(tab) = self.core.active else {
return self.no_document(cx);
};
self.print(tab, cx);
}
fn print(&mut self, id: u64, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |viewer, _, cx| viewer.print(id, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
let printing = tab.work.join("print.pdf");
let out = printing.clone();
log::info!("pdf: prints");
self.read_in(
id,
move |worker, doc| worker.get()?.print_copy(*doc, &out),
move |viewer, done, _, cx| match done {
Ok(()) => ely_office::print_later(printing, cx),
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// Asks where, then writes what the dialog asks for.
fn choose_export(&mut self, cx: &mut Context<Self>) {
let Some(asked) = self.exporting.take() else {
return;
};
cx.notify();
let (Some(tab), Some(view)) = (self.tab(asked.tab), self.tab(asked.tab).map(|tab| tab.view.clone())) else {
return;
};
let Some(dir) = ely_office::folder(tab) else {
return self.panel_failed("no documents folder", cx);
};
let (id, stem) = (asked.tab, stem(&tab.name));
if asked.kind == Kind::Images {
let read = view.read(cx);
let pages = if asked.all { (0..read.sizes.len()).collect() } else { read.chosen_pages() };
let names = image_names(&stem, &pages, read.sizes.len());
let (dpi, version) = (asked.dpi as f32, read.version);
return self.pick(true, cx, move |viewer, folder, cx| {
let targets = names.iter().map(|name| folder.join(name)).collect();
viewer.write_images(id, (pages, version), dpi, targets, cx)
});
}
let name = match asked.kind {
Kind::Text => format!("{stem}.txt"),
_ => format!("{stem}-{}.pdf", Self::text(cx, "pdf.export.tail", &[])),
};
let chosen = cx.prompt_for_new_path(&dir, Some(&name));
cx.spawn(async move |this, cx| {
let chosen = match chosen.await {
Ok(Ok(path)) => Ok(path),
Ok(Err(error)) => Err(error.to_string()),
Err(error) => Err(error.to_string()),
};
in_window(&this, cx, |viewer, _, cx| match chosen {
Ok(Some(target)) => viewer.write_export(id, asked.kind, asked.how, target, cx),
Ok(None) => log::info!("pdf: the export panel was cancelled"),
Err(error) => viewer.panel_failed(&error, cx),
})
})
.detach_and_log_err(cx);
}
/// Page numbers hold for `version` only; an edit since refuses.
pub(crate) fn write_images(&mut self, id: u64, (pages, version): (Vec<usize>, u64), dpi: f32, targets: Vec<PathBuf>, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |viewer, _, cx| viewer.write_images(id, (pages, version), dpi, targets, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
if tab.view.read(cx).version != version {
return self.toast(Self::text(cx, "pdf.object.changed", &[]), Severity::Warning, cx);
}
let work = tab.work.join("page.png");
let folder = targets.first().and_then(|target| target.parent()).map(PathBuf::from);
log::info!("pdf: exports {} page images", pages.len());
self.read_in(
id,
move |worker, doc| {
let claimed = ely_files::claim_all(&targets)?;
let written = pages.iter().zip(claimed).try_for_each(|(page, file)| {
worker.get()?.export_image(*doc, *page, dpi, &work)?;
crate::ask::copy_into(&work, file)
});
// A failure leaves none of this run's files.
if let Err(error) = &written
&& let Err(left) = targets.iter().try_for_each(std::fs::remove_file)
{
return Err(io::Error::other(format!("{error}; then {left}")));
}
written.map(|()| targets.len())
},
move |viewer, done, _, cx| match done {
Ok(count) => {
let text = ely_ui::i18n::counted(cx, "pdf.export.images_done", count as u64, &[("count", &count.to_string())]);
match folder.clone() {
Some(folder) => viewer.toast_output(text, folder, cx),
None => viewer.toast(text, Severity::Success, cx),
}
}
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
pub(crate) fn write_export(&mut self, id: u64, kind: Kind, how: Optimizing, target: PathBuf, cx: &mut Context<Self>) {
if !self.settled(id, cx) {
return self.when_settled(id, move |viewer, _, cx| viewer.write_export(id, kind, how, target, cx));
}
let Some(tab) = self.tab(id) else {
return;
};
let shown = ely_office::files::file_name(&target);
match self.holder(&target) {
Ok(None) => {}
Ok(Some(_)) => return self.toast(Self::text(cx, "pdf.extract.taken", &[("name", &shown)]), Severity::Danger, cx),
Err(error) => return self.failed(&error, cx),
}
let (work, path, revision) = (tab.work.join("export.out"), tab.session.path().map(PathBuf::from), tab.session.revision());
let opened = target.clone();
log::info!("pdf: exports {}", if kind == Kind::Text { "text" } else { "an optimized copy" });
self.read_in(
id,
move |worker, doc| {
match kind {
Kind::Text => worker.get()?.export_text(*doc, &work)?,
_ => worker.get()?.optimize(*doc, &work, how)?,
}
let bytes = std::fs::read(&work)?;
std::fs::remove_file(&work)?;
let before = path.as_deref().map(std::fs::metadata).transpose()?.map(|meta| meta.len());
ely_files::export(&target, path.as_deref(), &bytes, revision)?;
Ok((before, bytes.len() as u64))
},
move |viewer, done, _, cx| match done {
Ok((Some(before), after)) if kind == Kind::Optimized => {
let sizes = [("name", shown.as_ref()), ("before", &crate::attach::size(before)), ("after", &crate::attach::size(after))];
viewer.toast_output(Self::text(cx, "pdf.export.optimized_done", &sizes), opened.clone(), cx)
}
Ok(_) => viewer.toast_output(Self::text(cx, "pdf.export.done", &[("name", &shown)]), opened.clone(), cx),
Err(error) => viewer.failed(&error, cx),
},
cx,
);
}
/// The shell's warning, with its details a press away.
pub fn file_banner(&self, cx: &mut Context<Self>) -> Option<Banner> {
if let Some(stopped) = self.stopped_banner(cx) {
return Some(stopped);
}
let banner = self.risk_banner(cx)?;
let (key, action): (&str, Box<dyn gpui::Action>) = match self.active_tab()?.risk? {
ely_office::Risk::Signed => ("pdf.sign.show", Box::new(crate::document::ShowSignatures)),
ely_office::Risk::Repaired => ("pdf.repair.show", Box::new(crate::document::ShowRepairReport)),
ely_office::Risk::Macros => return Some(banner),
};
let label = Self::text(cx, key, &[]);
Some(banner.action(label, move |window, cx| window.dispatch_action(action.boxed_clone(), cx)))
}
pub fn show_repair(&mut self, open: bool, cx: &mut Context<Self>) {
self.repair_open = open;
cx.notify();
}
/// MuPDF's words on what it found broken.
pub fn repair_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let report = self.active_view().filter(|_| self.repair_open)?.read(cx).repair.clone()?;
let name = self.active_tab()?.name.clone();
let this = cx.entity();
let dismiss = move |_: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.show_repair(false, cx));
let lines = report.lines().map(|line| div().child(line.to_string())).collect::<Vec<_>>();
let done = Self::text(cx, "pdf.sign.close", &[]);
let close = move |close: ely_gpui_component::overlays::Close| {
Button::new("pdf-repair-close", done).variant(ButtonVariant::Primary).on_click(move |_, window, cx| close(window, cx))
};
let card = Card::new("pdf-repair", Self::text(cx, "pdf.repair.title", &[("name", &name)]), dismiss)
.detail(Self::text(cx, "pdf.repair.detail", &[]))
.child(div().text_sm().max_h(gpui::px(320.0)).overflow_hidden().children(lines))
.action(close);
Some(card.into_any_element())
}
fn set_exporting(&mut self, cx: &mut Context<Self>, change: impl FnOnce(&mut Exporting)) {
if let Some(exporting) = self.exporting.as_mut() {
change(exporting);
}
cx.notify();
}
/// The export dialog, while open.
pub fn export_element(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
let asked = self.exporting.as_ref()?;
let optimized = (asked.kind == Kind::Optimized).then(|| self.optimize_rows(asked.how, cx));
let this = cx.entity();
let label = |key: &str| Self::text(cx, key, &[]);
let segments = |id: &'static str, selected: &str, choices: &[(&str, &str)], set: fn(&mut Exporting, &str)| {
let changed = this.clone();
choices
.iter()
.fold(SegmentedControl::new(id, selected.to_string()).size(ControlSize::Sm), |control, (value, key)| {
control.segment(value.to_string(), Self::text(cx, key, &[]), None)
})
.on_change(move |value, _, cx| {
let value = value.to_string();
changed.update(cx, |viewer, cx| viewer.set_exporting(cx, |asked| set(asked, &value)))
})
};
let kind = match asked.kind {
Kind::Images => "images",
Kind::Text => "text",
Kind::Optimized => "optimized",
};
let kinds = [("images", "pdf.export.images"), ("text", "pdf.export.text"), ("optimized", "pdf.export.optimized")];
let mut body = div().flex().flex_col().gap_3().child(segments("pdf-export-kind", kind, &kinds, |asked, value| {
asked.kind = match value {
"text" => Kind::Text,
"optimized" => Kind::Optimized,
_ => Kind::Images,
}
}));
match asked.kind {
Kind::Images => {
let dpi = asked.dpi.to_string();
let dpis = [("72", "pdf.export.dpi72"), ("150", "pdf.export.dpi150"), ("300", "pdf.export.dpi300")];
let pages = if asked.all { "all" } else { "picked" };
let rows = DescriptionList::new()
.item(label("pdf.export.resolution"), segments("pdf-export-dpi", &dpi, &dpis, |asked, value| asked.dpi = value.parse().expect("a listed dpi")))
.item(
label("pdf.export.pages"),
segments("pdf-export-pages", pages, &[("all", "pdf.export.all"), ("picked", "pdf.export.picked")], |asked, value| asked.all = value == "all"),
);
body = body.child(rows);
}
Kind::Text => {}
Kind::Optimized => body = body.children(optimized),
}
let closed = this.clone();
let dismiss = move |_: &mut Window, cx: &mut App| {
closed.update(cx, |viewer, cx| {
viewer.exporting = None;
cx.notify();
})
};
let go = label("pdf.export.go");
// MuPDF only shrinks pictures; `to` must stay below `from`.
let ready = !(asked.kind == Kind::Optimized && asked.how.to > 0 && asked.how.from <= asked.how.to);
let card = Card::new("pdf-export", label("pdf.export.title"), dismiss).child(body).action(ely_office::cancel(cx)).action(move |_| {
Button::new("pdf-export-go", go)
.variant(ButtonVariant::Primary)
.disabled(!ready)
.on_click(move |_, _, cx| this.update(cx, |viewer, cx| viewer.choose_export(cx)))
});
Some(card.into_any_element())
}
fn optimize_rows(&self, how: Optimizing, cx: &mut Context<Self>) -> AnyElement {
let this = cx.entity();
let set = |change: fn(&mut Optimizing, f64)| {
let this = this.clone();
move |value: f64, _: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.set_exporting(cx, |asked| change(&mut asked.how, value)))
};
let toggled = |change: fn(&mut Optimizing, bool)| {
let this = this.clone();
move |on: bool, _: &mut Window, cx: &mut App| this.update(cx, |viewer, cx| viewer.set_exporting(cx, |asked| change(&mut asked.how, on)))
};
let shrink = how.to > 0;
let mut rows = div().flex().flex_col().gap_2().child(
Checkbox::new("pdf-export-compress", how.compress).label(Self::text(cx, "pdf.export.compress", &[])).on_change(toggled(|how, on| how.compress = on)),
);
rows = rows.child(
Checkbox::new("pdf-export-shrink", shrink)
.label(Self::text(cx, "pdf.export.shrink", &[]))
.on_change(toggled(|how, on| (how.from, how.to) = if on { (300, 150) } else { (300, 0) })),
);
if shrink {
let from = NumberInput::new("pdf-export-from", f64::from(how.from)).range(72.0, 2400.0).suffix("dpi").label(Self::text(cx, "pdf.export.above", &[]));
let to = NumberInput::new("pdf-export-to", f64::from(how.to)).range(36.0, 1200.0).suffix("dpi").label(Self::text(cx, "pdf.export.to", &[]));
let list = DescriptionList::new()
.item(Self::text(cx, "pdf.export.above", &[]), from.on_change(set(|how, value| how.from = value.round() as u32)))
.item(Self::text(cx, "pdf.export.to", &[]), to.on_change(set(|how, value| how.to = value.round() as u32)));
rows = rows.child(list);
}
rows.into_any_element()
}
}