mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 12:08:46 -06:00
M10.3 (first formats): Export a PDF to images (PNG, 72–600 dpi) and text: engine export module (Exporter over the working file, so unsaved edits and hidden layers apply), export dialogs with page ranges, background export thread with progress notices (in place + downloads on the web), export.image/export.text commands, doc_export_images/doc_export_text tools (46 tools)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
fbab67c7dc
commit
ba944746e5
Generated
+1
@@ -3099,6 +3099,7 @@ dependencies = [
|
||||
name = "printcraft-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"png 0.18.1",
|
||||
"printcraft-annot",
|
||||
"printcraft-cos",
|
||||
"printcraft-edit",
|
||||
|
||||
@@ -216,6 +216,7 @@ impl Automation {
|
||||
self.apply(&a, Edit::DuplicatePages { pages })?
|
||||
}
|
||||
"page_set_box" => self.page_set_box(&a)?,
|
||||
"doc_export_images" | "doc_export_text" => self.export(name, &a)?,
|
||||
"doc_header_footer" | "doc_watermark" | "doc_background" | "doc_remove_marks" => self.marks(name, &a)?,
|
||||
"doc_unprotect" => {
|
||||
let mut out = self.apply(&a, Edit::RemoveProtection)?;
|
||||
@@ -348,6 +349,33 @@ impl Automation {
|
||||
self.apply(a, edit)
|
||||
}
|
||||
|
||||
fn export(&mut self, tool: &str, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
Some(_) => self.pages(a, "pages")?,
|
||||
None => (0..doc.info.pages.len()).collect(),
|
||||
};
|
||||
let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string();
|
||||
let mut ex = printcraft_engine::export::Exporter::new(doc);
|
||||
if tool == "doc_export_text" {
|
||||
let path = self.resolve(a.str("path")?, true)?;
|
||||
let text = ex.text_of(&pages).map_err(failed)?;
|
||||
write_atomic(&path, text.as_bytes())?;
|
||||
return Ok(json!({ "path": path.to_string_lossy(), "pages": pages.len(), "bytes": text.len() }));
|
||||
}
|
||||
let folder = self.resolve(a.str("folder")?, true)?;
|
||||
std::fs::create_dir_all(&folder).map_err(|e| failed(format!("{}: {e}", folder.display())))?;
|
||||
let dpi = a.opt_num("dpi")?.unwrap_or(150.0);
|
||||
let mut files = Vec::new();
|
||||
for p in pages {
|
||||
let png = ex.png(p, dpi).map_err(failed)?;
|
||||
let path = folder.join(format!("{stem}_page_{}.png", p + 1));
|
||||
write_atomic(&path, &png)?;
|
||||
files.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(json!({ "count": files.len(), "files": files }))
|
||||
}
|
||||
|
||||
fn page_set_box(&mut self, a: &Args) -> Result<Value> {
|
||||
use printcraft_engine::{BoxSpec, PageBox};
|
||||
let doc = self.doc(a)?;
|
||||
|
||||
@@ -346,6 +346,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("doc_remove_marks", "Remove header & footer, watermark or background", "Remove every header and footer, watermark or background PrintCraft (or a compatible tool) added. Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "kind": { "type": "string", "enum": ["header_footer", "watermark", "background"] } }), &["doc", "kind"])),
|
||||
t("doc_export_images", "Export pages as images", "Write pages as PNG files (`<name>_page_<n>.png`) into a folder, at a resolution (default 150 dpi). Includes unsaved edits.")
|
||||
.cmd("export.image")
|
||||
.with(schema(json!({ "doc": doc(), "folder": { "type": "string" }, "dpi": { "type": "number", "minimum": 18, "maximum": 1200 }, "pages": pages("to export (default: all)") }), &["doc", "folder"])),
|
||||
t("doc_export_text", "Export text", "Write the reading-order text of pages to a .txt file (pages separated by form feeds).")
|
||||
.cmd("export.text")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "pages": pages("to export (default: all)") }), &["doc", "path"])),
|
||||
t("edit_undo", "Undo", "Undo the last edit of a document.").cmd("edit.undo").with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("edit_redo", "Redo", "Redo the last undone edit of a document.").cmd("edit.redo").with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("command_list", "List commands", "Every registered PrintCraft command with its menu, shortcut, whether it is enabled now, and the tool that automates it.")
|
||||
|
||||
@@ -519,3 +519,16 @@ fn headers_watermarks_and_backgrounds_through_tools() {
|
||||
assert!(matches!(a.call("doc_header_footer", &json!({ "doc": doc })), Err(ToolError::Failed(_))), "no text");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "marked.pdf" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_images_and_text_through_tools() {
|
||||
let dir = workdir("export");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "out", "dpi": 72, "pages": [1, 3] }));
|
||||
assert_eq!(r["count"], 2);
|
||||
assert!(dir.join("out/a_page_3.png").exists());
|
||||
ok(&mut a, "doc_export_text", json!({ "doc": doc, "path": "a.txt" }));
|
||||
assert_eq!(std::fs::read_to_string(dir.join("a.txt")).unwrap(), "Page 1\n\u{c}Page 2\n\u{c}Page 3\n");
|
||||
assert!(a.call("doc_export_text", &json!({ "doc": doc, "path": "/etc/x.txt" })).is_err(), "confined to the root");
|
||||
}
|
||||
|
||||
@@ -17,3 +17,4 @@ printcraft-forms = { workspace = true }
|
||||
printcraft-edit = { workspace = true }
|
||||
printcraft-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
png = "0.18"
|
||||
|
||||
@@ -72,9 +72,9 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
|
||||
item("Microsoft Word (.docx)", "file-text", "export.docx", Planned("M10")),
|
||||
item("Spreadsheet (.xlsx)", "grid-3x3", "export.xlsx", Planned("M10")),
|
||||
item("Presentation (.pptx)", "presentation", "export.pptx", Planned("M10")),
|
||||
item("Image (PNG, JPEG, TIFF)", "image", "export.image", Planned("M10")),
|
||||
item("Image (PNG)", "image", "export.image", Ready),
|
||||
item("HTML web page", "file-symlink", "export.html", Planned("M10")),
|
||||
item("Text (plain / accessible)", "type", "export.text", Planned("M10")),
|
||||
item("Text (plain)", "type", "export.text", Ready),
|
||||
item("PostScript / EPS", "file-down", "export.ps", Planned("M10")),
|
||||
],
|
||||
}],
|
||||
|
||||
@@ -165,6 +165,8 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("comment.circle", "Draw an oval", None, None, Annotate, "circle"),
|
||||
c("form.fields", "Form fields panel", VIEW, None, Document, "list"),
|
||||
c("form.clear", "Clear form", EDIT, None, FillForms, "eraser"),
|
||||
c("export.image", "Export to image…", FILE, None, Document, "image"),
|
||||
c("export.text", "Export to text…", FILE, None, Document, "type"),
|
||||
c("edit.header_footer", "Add header & footer…", None, None, Modification, "heading"),
|
||||
c("edit.header_footer.update", "Update header & footer…", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
|
||||
c("edit.header_footer.remove", "Remove header & footer", None, None, Marks(crate::MarkKind::HeaderFooter), "heading"),
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
//! Export a PDF ▸ Image and Text (execution plan M10.3, the first formats).
|
||||
//!
|
||||
//! [`Exporter`] renders pages to PNG at a resolution and extracts reading-order text, from a
|
||||
//! document's working file (so unsaved edits and hidden layers are respected, as on screen).
|
||||
|
||||
use printcraft_render::{PageRenderer, RenderRequest, RequestKind};
|
||||
|
||||
use crate::Document;
|
||||
|
||||
/// Renders and extracts pages of one document state.
|
||||
pub struct Exporter {
|
||||
renderer: PageRenderer,
|
||||
pages: usize,
|
||||
}
|
||||
|
||||
/// What an export needs from a document, as plain values that can move to a worker thread.
|
||||
#[derive(Clone)]
|
||||
pub struct ExportSource {
|
||||
pub bytes: std::sync::Arc<Vec<u8>>,
|
||||
pub config: printcraft_render::RenderConfig,
|
||||
pub pages: usize,
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// The current state, for exporting on another thread.
|
||||
pub fn export_source(&self) -> ExportSource {
|
||||
ExportSource { bytes: self.bytes.clone(), config: self.config.clone(), pages: self.info.pages.len() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Exporter {
|
||||
pub fn new(doc: &Document) -> Self {
|
||||
Self::from_source(doc.export_source())
|
||||
}
|
||||
|
||||
pub fn from_source(src: ExportSource) -> Self {
|
||||
Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages }
|
||||
}
|
||||
|
||||
fn check(&self, page: usize) -> Result<(), String> {
|
||||
if page < self.pages { Ok(()) } else { Err(format!("page {} does not exist", page + 1)) }
|
||||
}
|
||||
|
||||
/// Page `page` (0-based) as a PNG at `dpi` (capped by the renderer's size limits).
|
||||
pub fn png(&mut self, page: usize, dpi: f64) -> Result<Vec<u8>, String> {
|
||||
self.check(page)?;
|
||||
let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() });
|
||||
if let Some(e) = r.error {
|
||||
return Err(format!("page {}: {e}", page + 1));
|
||||
}
|
||||
encode_png(r.width, r.height, &r.rgba)
|
||||
}
|
||||
|
||||
/// The reading-order text of a page.
|
||||
pub fn text(&mut self, page: usize) -> Result<String, String> {
|
||||
self.check(page)?;
|
||||
let r = self.renderer.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() });
|
||||
match r.text {
|
||||
Some(t) => Ok(t.plain_text()),
|
||||
None => Err(format!("page {}: {}", page + 1, r.error.unwrap_or_else(|| "no text layer".into()))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of several pages, separated by form feeds (as `pdftotext` does).
|
||||
pub fn text_of(&mut self, pages: &[usize]) -> Result<String, String> {
|
||||
let mut out = String::new();
|
||||
for (k, p) in pages.iter().enumerate() {
|
||||
if k > 0 {
|
||||
out.push('\u{c}');
|
||||
}
|
||||
out.push_str(self.text(*p)?.trim_end());
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA → PNG (straight alpha).
|
||||
pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let mut rgba = premultiplied.to_vec();
|
||||
for px in rgba.chunks_exact_mut(4) {
|
||||
let a = u32::from(px[3]);
|
||||
if a != 0 && a != 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut out, width, height);
|
||||
enc.set_color(png::ColorType::Rgba);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut w = enc.write_header().map_err(|e| e.to_string())?;
|
||||
w.write_image_data(&rgba).map_err(|e| e.to_string())?;
|
||||
w.finish().map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod catalog;
|
||||
pub mod commands;
|
||||
pub mod export;
|
||||
pub mod links;
|
||||
|
||||
pub use printcraft_organize::{BoxSpec, PageBox, SplitBy, split_ranges};
|
||||
|
||||
@@ -701,3 +701,19 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() {
|
||||
assert!(matches!(s.apply(id, Edit::RemoveMarks { kind: MarkKind::Watermark }), Err(EditError::Edit(_))));
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_images_and_text_follow_the_working_file() {
|
||||
let (mut s, id) = session_with(2);
|
||||
s.apply(id, Edit::RotatePages { pages: vec![1], degrees: 90 }).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut ex = export::Exporter::new(doc);
|
||||
let png = ex.png(1, 144.0).unwrap();
|
||||
assert_eq!(&png[1..4], b"PNG");
|
||||
// 200×300 pt rotated → 300×200 pt at 2 px/pt.
|
||||
let w = u32::from_be_bytes(png[16..20].try_into().unwrap());
|
||||
let h = u32::from_be_bytes(png[20..24].try_into().unwrap());
|
||||
assert_eq!((w, h), (600, 400));
|
||||
assert_eq!(ex.text_of(&[0, 1]).unwrap(), "Page 1\n\u{c}Page 2\n");
|
||||
assert!(ex.png(5, 72.0).is_err());
|
||||
}
|
||||
|
||||
@@ -193,6 +193,8 @@ impl PrintCraftApp {
|
||||
};
|
||||
self.apply_edit(Edit::RemoveMarks { kind });
|
||||
}
|
||||
"export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)),
|
||||
"export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)),
|
||||
"page.duplicate" => {
|
||||
self.apply_edit(Edit::DuplicatePages { pages: targets });
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
||||
let mut protect_now = false;
|
||||
let mut boxes_now = false;
|
||||
let mut marks_now = false;
|
||||
let mut export_now = false;
|
||||
let t = Tokens::get(ctx);
|
||||
let mut close = false;
|
||||
let mut next = dialog;
|
||||
@@ -224,6 +225,12 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
||||
split_ready = Some(by);
|
||||
}
|
||||
}
|
||||
Dialog::Export(kind) => {
|
||||
let (apply, cancel) = crate::export_ui::body(ui, app, &t, kind);
|
||||
export_now = apply;
|
||||
close = apply || cancel;
|
||||
return;
|
||||
}
|
||||
Dialog::Marks(kind) => {
|
||||
ui.set_width(720.0);
|
||||
let (apply, cancel) = crate::marks_ui::body(ui, app, &t, kind);
|
||||
@@ -441,6 +448,9 @@ pub fn show(app: &mut PrintCraftApp, ctx: &egui::Context) {
|
||||
app.discard_recovered(&keys);
|
||||
}
|
||||
}
|
||||
if export_now && let Dialog::Export(kind) = dialog {
|
||||
app.start_export(kind);
|
||||
}
|
||||
if marks_now && let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids()) {
|
||||
let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0);
|
||||
let edit = crate::marks_ui::edit(&app.marks_draft, kind, count);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Export a PDF ▸ Image (PNG) and Text (Acrobat's Export a PDF tool, first formats).
|
||||
//!
|
||||
//! On the desktop the export runs on a worker thread and reports progress in the notice bar;
|
||||
//! on the web it runs in place and downloads the files.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui::{Align, Layout};
|
||||
use printcraft_engine::export::{ExportSource, Exporter};
|
||||
|
||||
use crate::marks_ui::PageRange;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PrintCraftApp, widgets};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ExportKind {
|
||||
Image,
|
||||
Text,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ExportDraft {
|
||||
pub dpi: f64,
|
||||
pub range: PageRange,
|
||||
}
|
||||
|
||||
impl Default for ExportDraft {
|
||||
fn default() -> Self {
|
||||
Self { dpi: 150.0, range: PageRange::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Progress of a background export: (done, total, final message once finished).
|
||||
pub type ExportStatus = Arc<Mutex<Option<(usize, usize, Option<String>)>>>;
|
||||
|
||||
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PrintCraftApp, t: &Tokens, kind: ExportKind) -> (bool, bool) {
|
||||
let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(0);
|
||||
let d = &mut app.export_draft;
|
||||
ui.label(
|
||||
egui::RichText::new(match kind {
|
||||
ExportKind::Image => "Export to Image (PNG)",
|
||||
ExportKind::Text => "Export to Text",
|
||||
})
|
||||
.font(theme::semibold(18.0)),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
if kind == ExportKind::Image {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Resolution");
|
||||
egui::ComboBox::from_id_salt("export-dpi").selected_text(format!("{} pixels/inch", d.dpi)).show_ui(ui, |ui| {
|
||||
for dpi in [72.0, 96.0, 150.0, 300.0, 600.0] {
|
||||
ui.selectable_value(&mut d.dpi, dpi, format!("{dpi} pixels/inch"));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.label(egui::RichText::new("One PNG file per page, named after the document.").small().color(t.text_faint));
|
||||
} else {
|
||||
ui.label(egui::RichText::new("Plain text in reading order; pages are separated by form feeds.").small().color(t.text_faint));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Pages").font(theme::semibold(12.5)));
|
||||
d.range.ui(ui, count);
|
||||
ui.add_space(12.0);
|
||||
let (mut apply, mut cancel) = (false, false);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let ok = !d.range.pages(count).is_empty();
|
||||
if ui.add_enabled_ui(ok, |ui| widgets::pill_button(ui, "Export", true)).inner.clicked() {
|
||||
apply = true;
|
||||
}
|
||||
if widgets::pill_button(ui, "Cancel", false).clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
(apply, cancel)
|
||||
}
|
||||
|
||||
/// Write the files (`name`, bytes) produced for `pages`, reporting progress.
|
||||
fn run(
|
||||
src: ExportSource,
|
||||
kind: ExportKind,
|
||||
dpi: f64,
|
||||
pages: Vec<usize>,
|
||||
stem: String,
|
||||
mut sink: impl FnMut(&str, Vec<u8>) -> Result<(), String>,
|
||||
status: &ExportStatus,
|
||||
) -> String {
|
||||
let mut ex = Exporter::from_source(src);
|
||||
let total = pages.len();
|
||||
let set = |done: usize, msg: Option<String>| {
|
||||
if let Ok(mut s) = status.lock() {
|
||||
*s = Some((done, total, msg));
|
||||
}
|
||||
};
|
||||
match kind {
|
||||
ExportKind::Image => {
|
||||
for (k, p) in pages.iter().enumerate() {
|
||||
set(k, None);
|
||||
let result = ex.png(*p, dpi).and_then(|png| sink(&format!("{stem}_page_{}.png", p + 1), png));
|
||||
if let Err(e) = result {
|
||||
return format!("Export stopped: {e}");
|
||||
}
|
||||
}
|
||||
format!("Exported {total} image{}", if total == 1 { "" } else { "s" })
|
||||
}
|
||||
ExportKind::Text => {
|
||||
set(0, None);
|
||||
match ex.text_of(&pages).and_then(|text| sink(&format!("{stem}.txt"), text.into_bytes())) {
|
||||
Ok(()) => format!("Exported the text of {total} page{}", if total == 1 { "" } else { "s" }),
|
||||
Err(e) => format!("Export stopped: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PrintCraftApp {
|
||||
/// Start exporting the active document with the dialog's settings.
|
||||
pub(crate) fn start_export(&mut self, kind: ExportKind) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let src = doc.export_source();
|
||||
let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string();
|
||||
let pages = self.export_draft.range.pages(src.pages);
|
||||
let dpi = self.export_draft.dpi;
|
||||
let status: ExportStatus = Arc::new(Mutex::new(Some((0, pages.len(), None))));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let dir = match &self.export_dir_override {
|
||||
Some(d) => Some(std::path::PathBuf::from(d)),
|
||||
None => rfd::FileDialog::new().set_title("Choose a folder for the exported files").pick_folder(),
|
||||
};
|
||||
let Some(dir) = dir else { return };
|
||||
let st = status.clone();
|
||||
let shown = dir.display().to_string();
|
||||
let work = move || {
|
||||
let sink = |name: &str, bytes: Vec<u8>| {
|
||||
crate::editing::write_atomically(&dir.join(name).to_string_lossy(), &bytes).map_err(|e| format!("{name}: {e}"))
|
||||
};
|
||||
let msg = run(src, kind, dpi, pages, stem, sink, &st);
|
||||
if let Ok(mut s) = st.lock() {
|
||||
let (done, total) = s.as_ref().map_or((0, 0), |(d, t, _)| (*d, *t));
|
||||
*s = Some((done.max(total), total, Some(format!("{msg} to {shown}"))));
|
||||
}
|
||||
};
|
||||
if self.export_dir_override.is_some() {
|
||||
work(); // tests and automation: synchronous
|
||||
} else {
|
||||
std::thread::Builder::new().name("printcraft-export".into()).spawn(work).ok();
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let msg = run(src, kind, dpi, pages, stem, |name, bytes| crate::editing::download(name, &bytes), &status);
|
||||
if let Ok(mut s) = status.lock() {
|
||||
*s = Some((0, 0, Some(msg)));
|
||||
}
|
||||
}
|
||||
self.export_status = Some(status);
|
||||
}
|
||||
|
||||
/// Show export progress, and the result once it is done.
|
||||
pub(crate) fn poll_export(&mut self) {
|
||||
let Some(st) = self.export_status.clone() else { return };
|
||||
let snapshot = st.lock().ok().and_then(|s| s.clone());
|
||||
match snapshot {
|
||||
Some((_, _, Some(msg))) => {
|
||||
self.export_status = None;
|
||||
self.notify(msg);
|
||||
}
|
||||
Some((done, total, None)) if total > 1 => self.notify(format!("Exporting… {done} of {total}")),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(ctx) = &self.ctx {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod comments;
|
||||
mod comments_panel;
|
||||
pub mod control;
|
||||
mod crop;
|
||||
mod export_ui;
|
||||
mod marks_ui;
|
||||
/// Header & footer / watermark / background dialog types (tests and automation).
|
||||
pub mod marks {
|
||||
@@ -93,6 +94,8 @@ pub enum Dialog {
|
||||
PageBoxes,
|
||||
/// Add / Update Header and Footer, Watermark, Background.
|
||||
Marks(printcraft_engine::MarkKind),
|
||||
/// Export a PDF ▸ Image / Text.
|
||||
Export(export_ui::ExportKind),
|
||||
/// Documents from a session that ended unexpectedly.
|
||||
Recovery,
|
||||
}
|
||||
@@ -186,6 +189,10 @@ pub struct PrintCraftApp {
|
||||
pub boxes_draft: pageboxes::BoxesDraft,
|
||||
/// Header & footer / watermark / background dialog state.
|
||||
pub marks_draft: marks_ui::MarksDraft,
|
||||
/// Export dialog settings.
|
||||
pub export_draft: export_ui::ExportDraft,
|
||||
/// A running export's progress.
|
||||
export_status: Option<export_ui::ExportStatus>,
|
||||
/// The last web link the app asked the system to open (tests and automation).
|
||||
pub last_opened_url: Option<String>,
|
||||
}
|
||||
@@ -251,6 +258,8 @@ impl PrintCraftApp {
|
||||
protect_draft: Default::default(),
|
||||
boxes_draft: Default::default(),
|
||||
marks_draft: Default::default(),
|
||||
export_draft: Default::default(),
|
||||
export_status: None,
|
||||
number_draft: NumberDraft { from: 1, to: 1, style: printcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 },
|
||||
}
|
||||
}
|
||||
@@ -545,6 +554,8 @@ impl PrintCraftApp {
|
||||
"header-footer" => Some(Dialog::Marks(printcraft_engine::MarkKind::HeaderFooter)),
|
||||
"watermark" => Some(Dialog::Marks(printcraft_engine::MarkKind::Watermark)),
|
||||
"background" => Some(Dialog::Marks(printcraft_engine::MarkKind::Background)),
|
||||
"export-image" => Some(Dialog::Export(export_ui::ExportKind::Image)),
|
||||
"export-text" => Some(Dialog::Export(export_ui::ExportKind::Text)),
|
||||
"number-pages" => {
|
||||
// Same path as the menu, so the page range is seeded.
|
||||
self.execute("page.number");
|
||||
@@ -682,6 +693,7 @@ impl eframe::App for PrintCraftApp {
|
||||
self.autosave_tick(now);
|
||||
self.shortcuts(ctx);
|
||||
self.process_pending_edits();
|
||||
self.poll_export();
|
||||
self.process_file_requests();
|
||||
// Pull finished renders into textures for every open document.
|
||||
for view in &mut self.views {
|
||||
|
||||
@@ -44,7 +44,7 @@ impl PageRange {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, count: usize) {
|
||||
pub(crate) fn ui(&mut self, ui: &mut egui::Ui, count: usize) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(&mut self.all, true, "All pages");
|
||||
ui.radio_value(&mut self.all, false, "Pages from");
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Export a PDF ▸ Image and Text from the real shell (egui_kittest).
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use printcraft_ui_egui::PrintCraftApp;
|
||||
|
||||
const FIXTURE: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 200 100] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn export_dialogs_write_images_and_text() {
|
||||
let dir = std::env::temp_dir().join(format!("printcraft-export-ui-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let d = dir.clone();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PrintCraftApp::new();
|
||||
app.open_bytes("doc.pdf", None, FIXTURE.to_vec()).unwrap();
|
||||
app.export_dir_override = Some(d.to_string_lossy().into_owned());
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("export.image"));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Export to Image (PNG)");
|
||||
h.get_by_label("Export").click();
|
||||
h.run_steps(4);
|
||||
assert!(dir.join("doc_page_1.png").exists() && dir.join("doc_page_2.png").exists());
|
||||
h.get_by_label_contains("Exported 2 images");
|
||||
assert!(h.state_mut().execute("export.text"));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Export").click();
|
||||
h.run_steps(4);
|
||||
assert!(dir.join("doc.txt").exists());
|
||||
}
|
||||
@@ -6871,11 +6871,11 @@ title = "Export pages as PNG"
|
||||
area = "J"
|
||||
tier = "P0"
|
||||
milestone = "M10"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["page_render"]
|
||||
evidence = ["crates/automation/tests/automation.rs::open_inspect_render_and_find"]
|
||||
notes = "page_render tool and CLI render only; no Export UI"
|
||||
status = "shipped"
|
||||
commands = ["export.image"]
|
||||
tools = ["doc_export_images", "page_render"]
|
||||
evidence = ["crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/engine/src/tests.rs::export_images_and_text_follow_the_working_file", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"]
|
||||
notes = "PNG at 72–600 dpi, page ranges; includes unsaved edits; background thread with progress"
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-jpeg-tiff"
|
||||
@@ -6931,11 +6931,11 @@ title = "Export to plain text"
|
||||
area = "J"
|
||||
tier = "P0"
|
||||
milestone = "M10"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["text_extract"]
|
||||
evidence = ["crates/automation/tests/automation.rs::parallel_text_extraction_keeps_page_order_and_follows_edits", "crates/render/src/text.rs::two_columns_read_left_then_right_even_if_drawn_interleaved"]
|
||||
notes = "text_extract tool and CLI text; no Export UI"
|
||||
status = "shipped"
|
||||
commands = ["export.text"]
|
||||
tools = ["doc_export_text", "text_extract"]
|
||||
evidence = ["crates/ui-egui/tests/export.rs::export_dialogs_write_images_and_text", "crates/automation/tests/automation.rs::exporting_images_and_text_through_tools"]
|
||||
notes = "plain reading-order text; accessible text (tags) comes with M12"
|
||||
|
||||
[[feature]]
|
||||
id = "create.export-accessible-text"
|
||||
|
||||
Reference in New Issue
Block a user