From ba944746e571c66c38d1ecd61b9f7fdb558cbfd8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 23:09:49 -0400 Subject: [PATCH] =?UTF-8?q?M10.3=20(first=20formats):=20Export=20a=20PDF?= =?UTF-8?q?=20to=20images=20(PNG,=2072=E2=80=93600=20dpi)=20and=20text:=20?= =?UTF-8?q?engine=20export=20module=20(Exporter=20over=20the=20working=20f?= =?UTF-8?q?ile,=20so=20unsaved=20edits=20and=20hidden=20layers=20apply),?= =?UTF-8?q?=20export=20dialogs=20with=20page=20ranges,=20background=20expo?= =?UTF-8?q?rt=20thread=20with=20progress=20notices=20(in=20place=20+=20dow?= =?UTF-8?q?nloads=20on=20the=20web),=20export.image/export.text=20commands?= =?UTF-8?q?,=20doc=5Fexport=5Fimages/doc=5Fexport=5Ftext=20tools=20(46=20t?= =?UTF-8?q?ools)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/automation/src/lib.rs | 28 ++++ crates/automation/src/tools.rs | 6 + crates/automation/tests/automation.rs | 13 ++ crates/engine/Cargo.toml | 1 + crates/engine/src/catalog.rs | 4 +- crates/engine/src/commands.rs | 2 + crates/engine/src/export.rs | 97 ++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 16 +++ crates/ui-egui/src/commands.rs | 2 + crates/ui-egui/src/dialogs.rs | 10 ++ crates/ui-egui/src/export_ui.rs | 176 ++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 12 ++ crates/ui-egui/src/marks_ui.rs | 2 +- crates/ui-egui/tests/export.rs | 40 ++++++ parity/acrobat-features.toml | 20 +-- 17 files changed, 418 insertions(+), 13 deletions(-) create mode 100644 crates/engine/src/export.rs create mode 100644 crates/ui-egui/src/export_ui.rs create mode 100644 crates/ui-egui/tests/export.rs diff --git a/Cargo.lock b/Cargo.lock index cc267f323..173b63039 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3099,6 +3099,7 @@ dependencies = [ name = "printcraft-engine" version = "0.1.0" dependencies = [ + "png 0.18.1", "printcraft-annot", "printcraft-cos", "printcraft-edit", diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index de8b99fdb..45eda985a 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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 { + 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 { use printcraft_engine::{BoxSpec, PageBox}; let doc = self.doc(a)?; diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 4d3951174..4de3478f4 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -346,6 +346,12 @@ pub fn tools() -> Vec { 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 (`_page_.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.") diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 36ee49d25..4451af67a 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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"); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 51b196f28..3186dc065 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -17,3 +17,4 @@ printcraft-forms = { workspace = true } printcraft-edit = { workspace = true } printcraft-model = { workspace = true } thiserror = { workspace = true } +png = "0.18" diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 463cff0cc..1a52d2460 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -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")), ], }], diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 8c19c1847..bee374b92 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -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"), diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs new file mode 100644 index 000000000..eba2cd1e8 --- /dev/null +++ b/crates/engine/src/export.rs @@ -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>, + 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, 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 { + 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 { + 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, 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) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ccae80091..325d9e77a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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}; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ccf2e9bac..602225437 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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()); +} diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 290ec2e4c..fee96e36b 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -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 }); } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6ae21fcc0..68537c7aa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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); diff --git a/crates/ui-egui/src/export_ui.rs b/crates/ui-egui/src/export_ui.rs new file mode 100644 index 000000000..21b605948 --- /dev/null +++ b/crates/ui-egui/src/export_ui.rs @@ -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)>>>; + +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, + stem: String, + mut sink: impl FnMut(&str, Vec) -> Result<(), String>, + status: &ExportStatus, +) -> String { + let mut ex = Exporter::from_source(src); + let total = pages.len(); + let set = |done: usize, msg: Option| { + 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| { + 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)); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 132ca446f..4424c83cf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, /// The last web link the app asked the system to open (tests and automation). pub last_opened_url: Option, } @@ -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 { diff --git a/crates/ui-egui/src/marks_ui.rs b/crates/ui-egui/src/marks_ui.rs index b077c1d80..1bdc75e6c 100644 --- a/crates/ui-egui/src/marks_ui.rs +++ b/crates/ui-egui/src/marks_ui.rs @@ -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"); diff --git a/crates/ui-egui/tests/export.rs b/crates/ui-egui/tests/export.rs new file mode 100644 index 000000000..63a7004cd --- /dev/null +++ b/crates/ui-egui/tests/export.rs @@ -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()); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 8e19b7316..7ae51aa0f 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"