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:
Brandon Thomas
2026-10-01 23:09:49 -04:00
co-authored by Claude Opus 5.5
parent fbab67c7dc
commit ba944746e5
17 changed files with 418 additions and 13 deletions
Generated
+1
View File
@@ -3099,6 +3099,7 @@ dependencies = [
name = "printcraft-engine"
version = "0.1.0"
dependencies = [
"png 0.18.1",
"printcraft-annot",
"printcraft-cos",
"printcraft-edit",
+28
View File
@@ -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)?;
+6
View File
@@ -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.")
+13
View File
@@ -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");
}
+1
View File
@@ -17,3 +17,4 @@ printcraft-forms = { workspace = true }
printcraft-edit = { workspace = true }
printcraft-model = { workspace = true }
thiserror = { workspace = true }
png = "0.18"
+2 -2
View File
@@ -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")),
],
}],
+2
View File
@@ -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"),
+97
View File
@@ -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)
}
+1
View File
@@ -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};
+16
View File
@@ -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());
}
+2
View File
@@ -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 });
}
+10
View File
@@ -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);
+176
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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");
+40
View File
@@ -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());
}
+10 -10
View File
@@ -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"