mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 11:17:10 -04:00
M3: keep late startup URLs from changing the active document (#583)
Co-authored-by: Tevinhead <[email protected]> Co-authored-by: Codex <[email protected]> Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
6 files changed
+316
-25
No files matched your search
@@ -96,6 +96,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
|
||||
| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:<br>- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;<br>- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.<br>Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.<br>Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel; late browser startup downloads preserve the current document and Save target (#171). |
|
||||
| M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:<br>- command registry (menus, shortcuts and palette all use it);<br>- undo/redo; incremental, atomic and encrypted saves;<br>- autosave and crash recovery;<br>- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;<br>- CLI `edit/combine/extract/split`.<br>Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web |
|
||||
| M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page |
|
||||
| M6 | Forms + JavaScript | 160–320 | 45% | 95–185 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done. Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA |
|
||||
@@ -129,6 +130,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
## Log
|
||||
|
||||
- **2026-10-09 (M5, line-ending labels):** Comment properties names each line ending in words ("Reverse open arrow", "Butt", …) and those labels are translated. The PDF `/LE` token is unchanged. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (M3, #171):** A browser startup `?file=` download opens in a background tab once the user has started working, so it cannot switch the active document or the next Save. Deliberate opens still activate their tab; Home, Combine files, workspace choices and another file's password prompt are preserved. Initial-view geometry is applied to the background document, and its script notices wait until its tab is selected. Synthetic shell tests cover the race, saving the local edit, tab switching, encrypted arrivals, errors and converted text. Milestone and overall effort estimates unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (M2, JPX sample normalization):** JPEG 2000 rescaling uses bounded integer arithmetic for supported 1–32-bit samples, including the 32-bit boundary that previously shifted past the integer width. Zero-sized rows are refused before scaling. Small contributor-original codestreams exercise decoding and rendering; endpoint and standard-depth regressions cover the same helper. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
@@ -29,7 +29,7 @@ fn main() {
|
||||
}
|
||||
// `?file=<url>` opens a PDF from a URL (same-origin or CORS-enabled).
|
||||
if let Some(url) = query_param("file") {
|
||||
let inbox = app.inbox.clone();
|
||||
let inbox = app.startup_inbox.clone();
|
||||
let failed = app.failed_inbox.clone();
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
|
||||
+79
-22
@@ -343,6 +343,8 @@ pub struct PasswordPrompt {
|
||||
pub bytes: std::sync::Arc<Vec<u8>>,
|
||||
pub input: String,
|
||||
pub error: Option<String>,
|
||||
/// A late startup file must stay in the background even after it is unlocked.
|
||||
activate: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
@@ -412,6 +414,10 @@ pub struct PdfCraftApp {
|
||||
pub full_screen: bool,
|
||||
/// Files delivered asynchronously (web drag-and-drop, web file picker).
|
||||
pub inbox: Inbox,
|
||||
/// The browser's startup `?file=` download. Unlike a deliberate Open or drop, a late
|
||||
/// arrival opens in the background once the user has started working (#171).
|
||||
pub startup_inbox: Inbox,
|
||||
startup_superseded: bool,
|
||||
/// Asynchronous opens that failed (web `?file=` fetches), shown as a notice.
|
||||
pub failed_inbox: FailedInbox,
|
||||
/// Requests from the operating system, polled every frame (macOS Apple events).
|
||||
@@ -656,6 +662,8 @@ impl PdfCraftApp {
|
||||
password_prompt: None,
|
||||
full_screen: false,
|
||||
inbox: Default::default(),
|
||||
startup_inbox: Default::default(),
|
||||
startup_superseded: false,
|
||||
failed_inbox: Default::default(),
|
||||
os_events: None,
|
||||
close_request: None,
|
||||
@@ -766,12 +774,14 @@ impl PdfCraftApp {
|
||||
fn apply_initial_view(&mut self, index: usize, v: &pdfcraft_engine::InitialView) {
|
||||
use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N};
|
||||
let pages = self.session.get(self.views[index].id).map_or(0, |d| d.info.pages.len());
|
||||
match v.navigation {
|
||||
N::PageOnly => {}
|
||||
N::Bookmarks => self.right = Some(RightPanel::Bookmarks),
|
||||
N::Pages => self.right = Some(RightPanel::Pages),
|
||||
N::Attachments => self.right = Some(RightPanel::Attachments),
|
||||
N::Layers => self.right = Some(RightPanel::Layers),
|
||||
if self.active == Some(index) {
|
||||
match v.navigation {
|
||||
N::PageOnly => {}
|
||||
N::Bookmarks => self.right = Some(RightPanel::Bookmarks),
|
||||
N::Pages => self.right = Some(RightPanel::Pages),
|
||||
N::Attachments => self.right = Some(RightPanel::Attachments),
|
||||
N::Layers => self.right = Some(RightPanel::Layers),
|
||||
}
|
||||
}
|
||||
let view = &mut self.views[index];
|
||||
match v.layout {
|
||||
@@ -801,21 +811,37 @@ impl PdfCraftApp {
|
||||
|
||||
/// Open a document and make it the active tab. Encrypted files raise the password prompt.
|
||||
pub fn open_bytes(&mut self, name: &str, path: Option<String>, bytes: Vec<u8>) -> Result<(), String> {
|
||||
// Images and text files become new, unsaved PDFs (Create a PDF).
|
||||
if let Some(r) = self.open_converted(name, &bytes) {
|
||||
return r;
|
||||
}
|
||||
self.try_open(name, path, std::sync::Arc::new(bytes), None)
|
||||
self.startup_superseded = true;
|
||||
self.open_bytes_with_focus(name, path, bytes, true)
|
||||
}
|
||||
|
||||
fn try_open(&mut self, name: &str, path: Option<String>, bytes: std::sync::Arc<Vec<u8>>, password: Option<&str>) -> Result<(), String> {
|
||||
fn open_bytes_with_focus(&mut self, name: &str, path: Option<String>, bytes: Vec<u8>, activate: bool) -> Result<(), String> {
|
||||
// Images and text files become new, unsaved PDFs (Create a PDF).
|
||||
let active = self.active;
|
||||
if let Some(r) = self.open_converted(name, &bytes) {
|
||||
if !activate {
|
||||
self.active = active;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
self.try_open(name, path, std::sync::Arc::new(bytes), None, activate)
|
||||
}
|
||||
|
||||
fn try_open(
|
||||
&mut self,
|
||||
name: &str,
|
||||
path: Option<String>,
|
||||
bytes: std::sync::Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
activate: bool,
|
||||
) -> Result<(), String> {
|
||||
use pdfcraft_render::OpenError;
|
||||
let size = bytes.len();
|
||||
let id = match self.session.open(name, path.clone(), bytes.clone(), password) {
|
||||
Ok(id) => id,
|
||||
Err(e @ (OpenError::NeedsPassword | OpenError::WrongPassword)) => {
|
||||
let error = matches!(e, OpenError::WrongPassword).then(|| "Incorrect password. Try again.".to_string());
|
||||
self.password_prompt = Some(PasswordPrompt { name: name.to_string(), path, bytes, input: String::new(), error });
|
||||
self.password_prompt = Some(PasswordPrompt { name: name.to_string(), path, bytes, input: String::new(), error, activate });
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => return Err(e.to_string()),
|
||||
@@ -824,7 +850,7 @@ impl PdfCraftApp {
|
||||
let doc = self.session.get(id).ok_or("the document could not be opened")?;
|
||||
let pages = doc.info.pages.len();
|
||||
// Acrobat opens straight to the Comments panel when a document has comments.
|
||||
if self.right.is_none() {
|
||||
if activate && self.right.is_none() {
|
||||
self.right = if !doc.info.annotations.is_empty() {
|
||||
Some(RightPanel::Comments)
|
||||
} else if !doc.info.outline.is_empty() {
|
||||
@@ -835,12 +861,14 @@ impl PdfCraftApp {
|
||||
}
|
||||
let initial = doc.initial_view();
|
||||
self.views.push(DocView::new(id, &doc.info, self.view_defaults));
|
||||
self.active = Some(self.views.len() - 1);
|
||||
if activate {
|
||||
self.active = Some(self.views.len() - 1);
|
||||
}
|
||||
self.apply_initial_view(self.views.len() - 1, &initial);
|
||||
if let Some(mode) = self.mode_override {
|
||||
if activate && let Some(mode) = self.mode_override {
|
||||
// Explicit mode options do not reset independent --tool / --left choices.
|
||||
self.mode = mode;
|
||||
} else if self.mode != self.default_mode {
|
||||
} else if activate && self.mode != self.default_mode {
|
||||
// Switch workspace like the mode bar, but a left panel the user (or `--left closed`)
|
||||
// closed stays closed, and an unchanged mode keeps the tool panel the user chose.
|
||||
let left_open = self.left_open;
|
||||
@@ -854,8 +882,12 @@ impl PdfCraftApp {
|
||||
}
|
||||
// What the form's scripts said while it opened (messages, errors) shows now, not with
|
||||
// the next edit.
|
||||
let out = self.session.take_js_output(id);
|
||||
self.handle_js(id, out);
|
||||
// Background documents keep their script output until their tab is selected: a print
|
||||
// or save request must never act on the current document instead.
|
||||
if activate {
|
||||
let out = self.session.take_js_output(id);
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -930,6 +962,7 @@ impl PdfCraftApp {
|
||||
/// Browsers read dropped files asynchronously; the bytes land in `inbox` and open next frame.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn open_dropped(&mut self, f: egui::DroppedFileHandle, ctx: &egui::Context) {
|
||||
self.startup_superseded = true;
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
@@ -990,10 +1023,11 @@ impl PdfCraftApp {
|
||||
pub fn submit_password(&mut self, password: Option<String>) {
|
||||
let Some(p) = self.password_prompt.take() else { return };
|
||||
let Some(pw) = password else { return };
|
||||
match self.try_open(&p.name, p.path, p.bytes, Some(&pw)) {
|
||||
match self.try_open(&p.name, p.path, p.bytes, Some(&pw), p.activate) {
|
||||
Err(e) => self.notify_fmt("Couldn't open {name}: {e}", &[("name", &p.name), ("e", &e.to_string())]),
|
||||
// A recovered encrypted document is open once the prompt is gone.
|
||||
Ok(()) if self.password_prompt.is_none() => {
|
||||
// A recovered encrypted document is open once its foreground prompt is gone.
|
||||
// Unlocking a background startup file must not finish another file's recovery.
|
||||
Ok(()) if p.activate && self.password_prompt.is_none() => {
|
||||
if let Some(meta) = self.pending_recovered.clone() {
|
||||
self.finish_recovery(&meta);
|
||||
}
|
||||
@@ -1027,6 +1061,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
|
||||
pub fn open_dialog(&mut self) {
|
||||
self.startup_superseded = true;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.pick(
|
||||
pickers::PickFor::Open,
|
||||
@@ -1685,6 +1720,8 @@ impl eframe::App for PdfCraftApp {
|
||||
}
|
||||
}
|
||||
self.sync_theme(ctx);
|
||||
// Remember user work even if its tab is subsequently closed or Home is selected.
|
||||
self.startup_superseded |= !self.views.is_empty() || self.combine_showing() || self.dialog.is_some() || self.password_prompt.is_some();
|
||||
// Before taking this frame's drop: the grid must be drawn once with the pointer where
|
||||
// the files were let go before the gap is read.
|
||||
self.finish_grid_drop(ctx);
|
||||
@@ -1711,6 +1748,22 @@ impl eframe::App for PdfCraftApp {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e.to_string())]);
|
||||
}
|
||||
}
|
||||
// Don't replace a pending prompt or import. A startup file may need its own password
|
||||
// prompt, so its bytes stay queued until the current interaction finishes.
|
||||
if self.password_prompt.is_none()
|
||||
&& self.image_import.is_none()
|
||||
&& self.dialog.is_none()
|
||||
&& self.close_request.is_none()
|
||||
&& self.pending_link.is_none()
|
||||
{
|
||||
let startup = self.startup_inbox.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default();
|
||||
for (name, bytes) in startup {
|
||||
let activate = !self.startup_superseded;
|
||||
if let Err(e) = self.open_bytes_with_focus(&name, None, bytes, activate) {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let failed: Vec<(String, String)> = self.failed_inbox.lock().map(|mut q| std::mem::take(&mut *q)).unwrap_or_default();
|
||||
for (name, e) in failed {
|
||||
self.notify_fmt("Couldn't open {name}: {e}", &[("name", &name), ("e", &e)]);
|
||||
@@ -1726,6 +1779,10 @@ impl eframe::App for PdfCraftApp {
|
||||
OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
if let Some((_, id)) = self.active_ids() {
|
||||
let out = self.session.take_js_output(id);
|
||||
self.handle_js(id, out);
|
||||
}
|
||||
if let Some(mut control) = self.control.take() {
|
||||
control.tick(ctx, self);
|
||||
self.control = Some(control);
|
||||
|
||||
@@ -424,3 +424,229 @@ fn a_failed_startup_url_is_reported_in_the_app() {
|
||||
assert!(h.state().views.is_empty(), "no document was opened");
|
||||
assert!(failed.lock().unwrap().is_empty(), "each failure is reported once");
|
||||
}
|
||||
|
||||
fn deliver_startup(h: &Harness<'static, PdfCraftApp>, name: &str, bytes: Vec<u8>) {
|
||||
h.state().startup_inbox.lock().unwrap().push((name.into(), bytes));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_startup_url_opens_in_front_when_the_user_has_not_started_working() {
|
||||
let mut h = harness(&[]);
|
||||
deliver_startup(&h, "startup.pdf", fixture(2));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
assert_eq!(pages(h.state(), 0), 2);
|
||||
assert!(h.state().startup_inbox.lock().unwrap().is_empty());
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views.len(), 1, "the arrival opens once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_startup_url_preserves_the_current_edit_workspace_and_save_target() {
|
||||
use pdfcraft_engine::{Edit, InitialView, Magnification, Navigation, Session};
|
||||
use pdfcraft_ui_egui::{LeftPanel, Mode, QuickTool, RightPanel};
|
||||
|
||||
// A downloaded PDF can ask to show a different panel and magnification. Its own view
|
||||
// should honour those settings without taking the user's workspace away.
|
||||
let mut s = Session::new();
|
||||
let id = s.open("late.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap();
|
||||
let initial = InitialView { navigation: Navigation::Bookmarks, magnification: Magnification::Percent(175.0), ..Default::default() };
|
||||
s.apply(id, Edit::SetInitialView(Box::new(initial))).unwrap();
|
||||
let late = s.save_bytes(id).unwrap().as_ref().clone();
|
||||
|
||||
let mut h = harness(&[3]);
|
||||
let active = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().apply_edit(Edit::DeletePages { pages: vec![1] });
|
||||
h.state_mut().mode = Mode::Edit;
|
||||
h.state_mut().right = Some(RightPanel::Pages);
|
||||
h.state_mut().left = LeftPanel::AllTools;
|
||||
h.state_mut().left_open = false;
|
||||
h.state_mut().quick_tool = QuickTool::Hand;
|
||||
deliver_startup(&h, "late.pdf", late);
|
||||
h.run_steps(2);
|
||||
|
||||
assert_eq!(h.state().active_ids().unwrap().1, active, "Save still targets the user's document");
|
||||
assert_eq!(h.state().mode, Mode::Edit);
|
||||
assert_eq!(h.state().right, Some(RightPanel::Pages));
|
||||
assert_eq!(h.state().left, LeftPanel::AllTools);
|
||||
assert!(!h.state().left_open);
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Hand);
|
||||
assert!(dirty(h.state(), 0));
|
||||
assert_eq!(h.state().views.len(), 2, "the startup file remains available in its own tab");
|
||||
assert_eq!(h.state().views[1].zoom, 1.75);
|
||||
|
||||
let dir = scratch("startup-save");
|
||||
let path = dir.join("saved.pdf");
|
||||
h.state_mut().save_override = Some(path.to_string_lossy().into_owned());
|
||||
assert!(h.state_mut().execute("file.save"));
|
||||
h.run_steps(2);
|
||||
let saved = PageRenderer::new(std::sync::Arc::new(std::fs::read(&path).unwrap()), Default::default());
|
||||
assert_eq!(saved.page_count(), 2, "the saved file contains the local deletion");
|
||||
assert!(!dirty(h.state(), 0), "the local document was saved, not the startup document");
|
||||
|
||||
h.get_by_label("late.pdf").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(1), "the user can choose the downloaded tab");
|
||||
h.get_by_label("saved.pdf").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(pages(h.state(), 0), 2, "the edit survives switching back");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_startup_url_preserves_home_even_after_all_other_tabs_are_closed() {
|
||||
for close in [false, true] {
|
||||
let mut h = harness(&[3]);
|
||||
if close {
|
||||
h.state_mut().close_tab(0);
|
||||
} else {
|
||||
h.get_by_label("Home").click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
assert_eq!(h.state().active, None);
|
||||
deliver_startup(&h, "late.pdf", fixture(1));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, None, "Home stays selected (closed tabs: {close})");
|
||||
h.get_by_label_contains("Welcome to PdfCraft");
|
||||
h.get_by_label("late.pdf").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().active.is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliberate_browser_file_opens_still_activate_the_new_tab() {
|
||||
let mut h = harness(&[3]);
|
||||
h.state().inbox.lock().unwrap().push(("picked.pdf".into(), fixture(2)));
|
||||
deliver_startup(&h, "late.pdf", fixture(1));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(1), "a deliberate Open wins even on the same frame");
|
||||
assert_eq!(pages(h.state(), 1), 2);
|
||||
assert_eq!(h.state().views.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_file_picker_supersedes_the_startup_url_even_if_the_picker_is_cancelled() {
|
||||
let mut h = harness(&[]);
|
||||
h.state_mut().pick_override = Some(vec![]);
|
||||
h.state_mut().execute("file.open");
|
||||
deliver_startup(&h, "late.pdf", fixture(1));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, None, "the startup file doesn't replace the user's newer Open action");
|
||||
assert_eq!(h.state().views.len(), 1, "it is still available as a background tab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_late_startup_file_reports_its_error_without_changing_the_active_document() {
|
||||
let mut h = harness(&[3]);
|
||||
deliver_startup(&h, "broken.pdf", b"not a PDF".to_vec());
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
let toast = &h.state().toast.as_ref().unwrap().0;
|
||||
assert!(toast.contains("broken.pdf"), "{toast}");
|
||||
assert!(h.state().startup_inbox.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
fn encrypted_fixture() -> Vec<u8> {
|
||||
use pdfcraft_engine::{Edit, Protection, Session};
|
||||
let mut s = Session::new();
|
||||
let id = s.open("encrypted.pdf", None, std::sync::Arc::new(fixture(1)), None).unwrap();
|
||||
s.apply(id, Edit::Protect(Protection { open_password: Some("test-password".into()), ..Default::default() })).unwrap();
|
||||
s.save_bytes(id).unwrap().as_ref().clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_startup_url_does_not_replace_another_documents_password_prompt() {
|
||||
for cancel in [false, true] {
|
||||
let mut h = harness(&[]);
|
||||
h.state_mut().open_bytes("local-encrypted.pdf", None, encrypted_fixture()).unwrap();
|
||||
deliver_startup(&h, "late.pdf", fixture(2));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().password_prompt.as_ref().unwrap().name, "local-encrypted.pdf");
|
||||
assert!(h.state().views.is_empty());
|
||||
assert_eq!(h.state().startup_inbox.lock().unwrap().len(), 1, "startup bytes wait for the prompt");
|
||||
h.state_mut().submit_password((!cancel).then(|| "test-password".into()));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().password_prompt.is_none());
|
||||
assert!(h.state().startup_inbox.lock().unwrap().is_empty());
|
||||
assert_eq!(h.state().active, if cancel { None } else { Some(0) });
|
||||
assert_eq!(h.state().views.len(), if cancel { 1 } else { 2 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlocking_a_late_startup_url_keeps_it_in_the_background() {
|
||||
let mut h = harness(&[3]);
|
||||
deliver_startup(&h, "late-encrypted.pdf", encrypted_fixture());
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
assert_eq!(h.state().password_prompt.as_ref().unwrap().name, "late-encrypted.pdf");
|
||||
h.state_mut().submit_password(Some("wrong-password".into()));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().password_prompt.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
h.state_mut().submit_password(Some("test-password".into()));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().password_prompt.is_none());
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
h.get_by_label("late-encrypted.pdf").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_startup_url_preserves_the_combine_files_tab() {
|
||||
let mut h = harness(&[]);
|
||||
h.state_mut().open_combine_tab();
|
||||
h.run_steps(2);
|
||||
deliver_startup(&h, "late.pdf", fixture(1));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().combine_showing());
|
||||
assert_eq!(h.state().active, None);
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_startup_url_waits_for_an_existing_dialog_to_close() {
|
||||
let mut h = harness(&[3]);
|
||||
h.state_mut().dialog = Some(pdfcraft_ui_egui::Dialog::Preferences);
|
||||
deliver_startup(&h, "late.pdf", fixture(1));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Preferences));
|
||||
assert_eq!(h.state().views.len(), 1);
|
||||
assert_eq!(h.state().startup_inbox.lock().unwrap().len(), 1);
|
||||
h.state_mut().dialog = None;
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
assert!(h.state().startup_inbox.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_startup_text_file_is_still_converted_without_stealing_focus() {
|
||||
let mut h = harness(&[3]);
|
||||
deliver_startup(&h, "late.txt", b"A downloaded text file".to_vec());
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
assert_eq!(h.state().views.len(), 2);
|
||||
assert_eq!(pages(h.state(), 1), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_background_startup_forms_script_notice_waits_until_its_tab_is_selected() {
|
||||
let template = pdfcraft_xfa::fixtures::scripted_template()
|
||||
.replace("if (qty.rawValue === null) qty.rawValue = 2;", r#"xfa.host.messageBox("Late form opened");"#);
|
||||
let mut h = harness(&[3]);
|
||||
deliver_startup(&h, "late-form.pdf", pdfcraft_xfa::fixtures::shell(&template));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().active, Some(0));
|
||||
assert!(h.state().toast.is_none(), "the background form must not interrupt the current document");
|
||||
h.get_by_label("late-form.pdf").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().toast.as_ref().unwrap().0, "Late form opened");
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
assert!(h.state().toast.is_none(), "the notice is delivered once");
|
||||
}
|
||||
@@ -19,6 +19,11 @@ so the site works at a domain root (`https://example.com/`), under a prefix
|
||||
(`https://example.com/tools/pdfcraft/`) and from a CDN bucket. The asset names carry a content
|
||||
hash, so they can be cached forever. Only `index.html` needs revalidation.
|
||||
|
||||
Add `?file=<URL-encoded document URL>` to open a PDF on startup. The document must be served
|
||||
from the same origin or allow the app's origin through CORS. If the user starts working before
|
||||
the download finishes, the downloaded file opens in a background tab; the current document,
|
||||
workspace and Save target stay selected. Click the downloaded file's tab to switch to it.
|
||||
|
||||
## Required server settings
|
||||
|
||||
- **MIME type:** serve `.wasm` as `application/wasm`. Browsers refuse to stream-compile it under
|
||||
|
||||
@@ -1778,8 +1778,8 @@ milestone = "M3"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = "web build works; IndexedDB/OPFS recent list and download flow are M3.10"
|
||||
evidence = ["crates/ui-egui/tests/pickers.rs::a_startup_url_opens_in_front_when_the_user_has_not_started_working", "crates/ui-egui/tests/pickers.rs::a_late_startup_url_preserves_the_current_edit_workspace_and_save_target", "crates/ui-egui/tests/pickers.rs::deliberate_browser_file_opens_still_activate_the_new_tab"]
|
||||
notes = "web build works; a late startup ?file= download opens in a background tab after the user starts working, preserving the active document and Save target (#171). IndexedDB/OPFS recent list and download flow are M3.10"
|
||||
|
||||
[[feature]]
|
||||
id = "view.large-document-performance"
|
||||
|
||||
Reference in new issue
Block a user