mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-26 02:58:32 -06:00
Merge track r1-app (fredapps-r1)
ROADMAP.md, docs/roadmap.md: keep next's status line; keep both progress-log entries
This commit is contained in:
@@ -158,6 +158,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
- **2026-10-16 (M10, #1201 partial):** Lossless image export applies each component's Decode pair. Indexed values round and clip before palette lookup. Color-key masks keep raw samples. The existing 16-bit precision limit remains. JPEG and mask Decode, ICC transforms, and calibrated colors remain outside this fix. Estimates unchanged.
|
||||
- **2026-10-16 (M3, #2739):** Wrapped revision buttons stay above Binding in Document Properties. A synthetic regression checks control bounds and opens a saved revision at two viewport widths. Estimates unchanged.
|
||||
- **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged.
|
||||
- **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged.
|
||||
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
|
||||
|
||||
@@ -70,6 +70,10 @@ fn main() {
|
||||
app.host_save = Some(bridge.save_fn());
|
||||
app.host_dirty = Some(bridge.dirty_fn());
|
||||
bridge.post_ready();
|
||||
} else if query_param("host").as_deref() == Some("parent") {
|
||||
eframe::web_sys::console::warn_1(
|
||||
&"PdfCraft: ?host=parent needs a framing page on the same origin; saves download instead".into(),
|
||||
);
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
@@ -130,7 +134,8 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
/// - `pdfcraft:dirty` (to the parent): `{ dirty }` whenever unsaved work appears or goes away.
|
||||
/// - `pdfcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
///
|
||||
/// Only the parent window on the app's own origin is heard or answered.
|
||||
/// Only the parent window on the app's own origin is heard or answered. With a parent on
|
||||
/// another origin there is no bridge: the app runs as if unframed and saves download.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host {
|
||||
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
|
||||
@@ -143,7 +148,9 @@ mod host {
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to).
|
||||
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent
|
||||
/// is on another origin: messages only go to this origin, so the browser would drop
|
||||
/// every save without an error while the document showed as saved (#2167).
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
@@ -151,6 +158,10 @@ mod host {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
// Reading another origin's location throws, which is `None` too.
|
||||
if parent.location().origin().ok()? != origin {
|
||||
return None;
|
||||
}
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
|
||||
@@ -377,6 +377,7 @@ fn app_creator<'a>(
|
||||
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
|
||||
app.restore(&json);
|
||||
}
|
||||
app.window_state = pdfcraft_ui_egui::window_state::WindowState::started_with(cc.storage);
|
||||
app.integrated_titlebar = integrated;
|
||||
app.window_controls = window_controls;
|
||||
app.system_text_scale = text_scale::system();
|
||||
|
||||
@@ -354,17 +354,20 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let ends = doc.revision_ends();
|
||||
if ends.len() > 1 {
|
||||
ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(ends.len().to_string());
|
||||
for n in (1..ends.len()).rev().take(12) {
|
||||
if ui
|
||||
.small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())]))
|
||||
.on_hover_text(tl!("Open the file as it was saved then"))
|
||||
.clicked()
|
||||
{
|
||||
open_revision = Some(n);
|
||||
// Reserve the full wrapped height before the grid starts the next row.
|
||||
ui.vertical(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(ends.len().to_string());
|
||||
for n in (1..ends.len()).rev().take(12) {
|
||||
if ui
|
||||
.small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())]))
|
||||
.on_hover_text(tl!("Open the file as it was saved then"))
|
||||
.clicked()
|
||||
{
|
||||
open_revision = Some(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
@@ -978,13 +978,15 @@ fn is_form_edit(edit: &Edit) -> bool {
|
||||
/// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the
|
||||
/// `Microsoft.OutlookForWindows` package folder for the new Outlook. Saving there either fails,
|
||||
/// because Outlook can still hold the file, or appears to work while the edits stay out of the
|
||||
/// email and are deleted with the cache. Matched on either separator and in any case, so it can
|
||||
/// be tested on every platform.
|
||||
/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads/<UUID>/` in
|
||||
/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on
|
||||
/// either separator and in any case, so it can be tested on every platform.
|
||||
pub(crate) fn is_mail_temp_copy(path: &str) -> bool {
|
||||
let p = path.replace('\\', "/").to_ascii_lowercase();
|
||||
p.contains("/inetcache/content.outlook/")
|
||||
|| p.contains("/temporary internet files/content.outlook/")
|
||||
|| p.contains("/microsoft.outlookforwindows")
|
||||
|| p.contains("/library/containers/com.apple.mail/data/library/mail downloads/")
|
||||
}
|
||||
|
||||
/// Write via a temporary file in the same directory and rename over the target, so a crash or
|
||||
@@ -1302,6 +1304,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apple_mail_attachment_copies_are_recognised() {
|
||||
for p in [
|
||||
"/Users/me/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/6F1C2A9E-3B4D-4E5F-8A7B-9C0D1E2F3A4B/booking.pdf",
|
||||
"/Users/me/library/containers/COM.APPLE.MAIL/data/library/mail downloads/6F1C2A9E/Booking (2).pdf",
|
||||
] {
|
||||
assert!(is_mail_temp_copy(p), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_paths_are_not_mail_copies() {
|
||||
for p in [
|
||||
@@ -1309,6 +1321,8 @@ mod tests {
|
||||
r"C:\Users\me\OneDrive - Contoso\Content.Outlook notes\Quote.pdf",
|
||||
r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\IE\Quote.pdf",
|
||||
"/home/me/Documents/Quote.pdf",
|
||||
"/Users/me/Downloads/Mail Downloads/booking.pdf",
|
||||
"/Users/me/Library/Containers/com.apple.Notes/Data/Library/Mail Downloads/booking.pdf",
|
||||
"Quote.pdf",
|
||||
] {
|
||||
assert!(!is_mail_temp_copy(p), "{p}");
|
||||
|
||||
@@ -142,6 +142,8 @@ pub mod updates;
|
||||
mod wheel_pager;
|
||||
mod widgets;
|
||||
pub mod window_controls;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod window_state;
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod open_worker_tests;
|
||||
@@ -744,6 +746,9 @@ pub struct PdfCraftApp {
|
||||
dialog_epoch: u64,
|
||||
/// The egui context, for commands that change window or theme state.
|
||||
ctx: Option<egui::Context>,
|
||||
/// The window's size and place kept across a minimized close (desktop).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub window_state: window_state::WindowState,
|
||||
styled: bool,
|
||||
fonts_ready: bool,
|
||||
/// The CJK fallback order the installed fonts follow (see `theme::installed_font_definitions`).
|
||||
@@ -1033,6 +1038,8 @@ impl PdfCraftApp {
|
||||
dialog_seen: None,
|
||||
dialog_epoch: 0,
|
||||
ctx: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
window_state: Default::default(),
|
||||
styled: false,
|
||||
fonts_ready: false,
|
||||
fonts_cjk: theme::CjkPreference::Default,
|
||||
@@ -2917,11 +2924,19 @@ impl eframe::App for PdfCraftApp {
|
||||
}
|
||||
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
// eframe has just stored the window as it is now: not while it is minimized.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let minimized = self.ctx.as_ref().is_some_and(|c| c.input(|i| i.viewport().minimized == Some(true)));
|
||||
self.window_state.keep(storage, minimized);
|
||||
}
|
||||
storage.set_string("pdfcraft", self.persist());
|
||||
}
|
||||
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.ctx = Some(ctx.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.window_state.repair(ctx);
|
||||
self.dialog_epoch();
|
||||
// Notices raised outside `ui` (opened files, OS events, the control channel) translate too.
|
||||
let lang = i18n::Lang::from_pref(&self.language);
|
||||
|
||||
@@ -55,9 +55,8 @@ fn reveal(path: &std::path::Path) -> Result<(), String> {
|
||||
return Err("The file no longer exists".into());
|
||||
}
|
||||
let platform = std::env::consts::OS;
|
||||
let run = |program: &str, args: &[std::ffi::OsString]| {
|
||||
std::process::Command::new(program).args(args).output().map(|o| o.status.success()).map_err(|e| e.to_string())
|
||||
};
|
||||
let run =
|
||||
|program: &str, args: &[std::ffi::OsString]| reveal_process(program, args).output().map(|o| o.status.success()).map_err(|e| e.to_string());
|
||||
if matches!(platform, "linux" | "freebsd") {
|
||||
// `dbus-send --print-reply` waits up to 2 s for the file manager (then xdg-open may run),
|
||||
// so it runs off the UI thread; a failure there is only logged.
|
||||
@@ -71,6 +70,24 @@ fn reveal(path: &std::path::Path) -> Result<(), String> {
|
||||
reveal_via(platform, &path, run)
|
||||
}
|
||||
|
||||
/// The process that runs `program` with `args`. Windows: `reveal_command` already quotes what
|
||||
/// Explorer needs quoted, and Rust's own quoting of an argument with a space breaks `/select,`
|
||||
/// (#2579), so the arguments go on the command line as written.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn reveal_process(program: &str, args: &[std::ffi::OsString]) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(program);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt as _;
|
||||
for arg in args {
|
||||
command.raw_arg(arg);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
command.args(args);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn reveal_via(
|
||||
platform: &str,
|
||||
@@ -95,9 +112,16 @@ fn reveal_via(
|
||||
fn reveal_command(platform: &str, path: &std::path::Path) -> Result<(&'static str, Vec<std::ffi::OsString>), String> {
|
||||
match platform {
|
||||
"windows" => {
|
||||
// Explorer uses /select,<object>, rather than a separate option value.
|
||||
let mut selection = std::ffi::OsString::from("/select,");
|
||||
// Explorer reads `/select,"<object>"`: the path quoted after the comma (#2579). Quoting
|
||||
// the whole argument instead ("/select,C:\a b.pdf", what an ordinary argument with a
|
||||
// space becomes) opens Documents. A Windows path can't contain `"`, so the quotes
|
||||
// can't be closed early; one that does is refused.
|
||||
if path.as_os_str().as_encoded_bytes().contains(&b'"') {
|
||||
return Err("The file name can't be shown in File Explorer".into());
|
||||
}
|
||||
let mut selection = std::ffi::OsString::from("/select,\"");
|
||||
selection.push(path);
|
||||
selection.push("\"");
|
||||
Ok(("explorer.exe", vec![selection]))
|
||||
}
|
||||
"macos" => Ok(("open", vec!["-R".into(), path.as_os_str().into()])),
|
||||
@@ -221,6 +245,19 @@ mod tests {
|
||||
assert!(reveal_via("linux", path, |_, _| Err("command missing".into())).is_err());
|
||||
}
|
||||
|
||||
/// #2579 (review of #2625): the `/select,"…"` argument reaches the process exactly as written.
|
||||
/// `cmd /c echo` prints its command line back; with Rust's own quoting (`args`) it would come
|
||||
/// back wrapped in quotes with the inner ones escaped, which Explorer reads as no selection.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn the_reveal_argument_reaches_the_process_as_written() {
|
||||
let (_, args) = reveal_command("windows", std::path::Path::new(r"C:\Reports\annual report.pdf")).unwrap();
|
||||
let mut echo: Vec<std::ffi::OsString> = ["/d", "/c", "echo"].map(std::ffi::OsString::from).to_vec();
|
||||
echo.extend(args);
|
||||
let out = reveal_process("cmd", &echo).output().unwrap();
|
||||
assert_eq!(String::from_utf8_lossy(&out.stdout).trim_end(), r#"/select,"C:\Reports\annual report.pdf""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_arguments_preserve_spaces_and_unicode() {
|
||||
let path = std::path::Path::new("/tmp/年度 report.pdf");
|
||||
@@ -229,10 +266,13 @@ mod tests {
|
||||
assert_eq!(args, [std::ffi::OsString::from("-R"), path.as_os_str().into()]);
|
||||
let (exe, args) = reveal_command("windows", path).unwrap();
|
||||
assert_eq!(exe, "explorer.exe");
|
||||
assert_eq!(args, [std::ffi::OsString::from("/select,/tmp/年度 report.pdf")]);
|
||||
assert_eq!(args, [std::ffi::OsString::from("/select,\"/tmp/年度 report.pdf\"")]);
|
||||
// #2579: the path is quoted after the comma, never the whole argument, which Explorer
|
||||
// reads as no selection and answers by opening Documents.
|
||||
let special = std::path::Path::new(r"C:\Reports\年度 report, #1 & (draft).pdf");
|
||||
let (_, windows) = reveal_command("windows", special).unwrap();
|
||||
assert_eq!(windows, [std::ffi::OsString::from(r"/select,C:\Reports\年度 report, #1 & (draft).pdf")]);
|
||||
assert_eq!(windows, [std::ffi::OsString::from(r#"/select,"C:\Reports\年度 report, #1 & (draft).pdf""#)]);
|
||||
assert!(reveal_command("windows", std::path::Path::new(r#"C:\a" b.pdf"#)).is_err(), "a quote can't end the path early");
|
||||
let (exe, args) = reveal_command("linux", path).unwrap();
|
||||
assert_eq!(exe, "dbus-send");
|
||||
assert_eq!(args[6], "array:string:file:///tmp/%E5%B9%B4%E5%BA%A6%20report.pdf");
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The window's saved size and place across starts. eframe stores them just before
|
||||
//! `App::save`, from the window as it is at that moment. A minimized window has neither: Windows
|
||||
//! reports it as 0 × 0 at (-32000, -32000), and the next start then opened at egui's 64-point
|
||||
//! floor in the screen's corner. So while the window is minimized the last settings it was shown
|
||||
//! with are put back, and a window that opens at that floor is given a usable size.
|
||||
|
||||
/// eframe's storage key for the window settings.
|
||||
const WINDOW_KEY: &str = "window";
|
||||
/// The size egui opens a stored size below (its floor for "ridiculously small" windows), in points.
|
||||
const EGUI_FLOOR: f32 = 64.0;
|
||||
/// The size a window that opened at [`EGUI_FLOOR`] is given (apps/pdfcraft opens a first start at
|
||||
/// this size too), at most nine tenths of the monitor.
|
||||
const USABLE: egui::Vec2 = egui::vec2(1440.0, 920.0);
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WindowState {
|
||||
/// The settings eframe last stored for a shown (not minimized) window.
|
||||
shown: Option<String>,
|
||||
/// The size at start has been checked ([`Self::repair`]).
|
||||
checked: bool,
|
||||
}
|
||||
|
||||
impl WindowState {
|
||||
/// The settings the window started with: what to put back if it is minimized before eframe
|
||||
/// stores a shown one.
|
||||
pub fn started_with(storage: Option<&dyn eframe::Storage>) -> Self {
|
||||
Self { shown: storage.and_then(|s| s.get_string(WINDOW_KEY)), checked: false }
|
||||
}
|
||||
|
||||
/// After eframe has stored the window: remember it while the window is shown, and while it
|
||||
/// is minimized put the last shown settings back (or none, so the next start opens at the
|
||||
/// default size).
|
||||
pub fn keep(&mut self, storage: &mut dyn eframe::Storage, minimized: bool) {
|
||||
if !minimized {
|
||||
self.shown = storage.get_string(WINDOW_KEY);
|
||||
} else if let Some(shown) = &self.shown {
|
||||
storage.set_string(WINDOW_KEY, shown.clone());
|
||||
} else {
|
||||
storage.remove_string(WINDOW_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/// Once the window's size is known: a shown window at egui's floor in both directions came
|
||||
/// from settings stored while it was minimized (before this fix), so give it a usable size.
|
||||
pub fn repair(&mut self, ctx: &egui::Context) {
|
||||
if self.checked {
|
||||
return;
|
||||
}
|
||||
let (rect, minimized, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().minimized, i.viewport().monitor_size));
|
||||
let Some(rect) = rect else { return };
|
||||
if minimized == Some(true) {
|
||||
return;
|
||||
}
|
||||
self.checked = true;
|
||||
if rect.width() <= EGUI_FLOOR + 0.5 && rect.height() <= EGUI_FLOOR + 0.5 {
|
||||
// The stored settings are the broken ones: never put them back.
|
||||
self.shown = None;
|
||||
let size = monitor.map_or(USABLE, |m| USABLE.min(m * 0.9));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use eframe::Storage as _;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Store(std::collections::HashMap<String, String>);
|
||||
|
||||
impl eframe::Storage for Store {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
self.0.get(key).cloned()
|
||||
}
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
self.0.insert(key.to_string(), value);
|
||||
}
|
||||
fn remove_string(&mut self, key: &str) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
|
||||
const SHOWN: &str = "(outer_position_pixels:Some((x:200.0,y:200.0)),inner_size_points:Some((x:1320.0,y:940.0)))";
|
||||
const MINIMIZED: &str = "(outer_position_pixels:Some((x:-32000.0,y:-32000.0)),inner_size_points:Some((x:0.0,y:0.0)))";
|
||||
|
||||
/// Closing PdfCraft while it was minimized stored a 0 × 0 window off screen.
|
||||
#[test]
|
||||
fn a_minimized_window_keeps_the_last_shown_settings() {
|
||||
let mut store = Store::default();
|
||||
store.set_string(WINDOW_KEY, SHOWN.into());
|
||||
let mut state = WindowState::started_with(Some(&store));
|
||||
// eframe stores the minimized window, then the app saves.
|
||||
store.set_string(WINDOW_KEY, MINIMIZED.into());
|
||||
state.keep(&mut store, true);
|
||||
assert_eq!(store.get_string(WINDOW_KEY).as_deref(), Some(SHOWN));
|
||||
// Shown again and resized: that is what is kept from then on.
|
||||
let moved = SHOWN.replace("200.0", "300.0");
|
||||
store.set_string(WINDOW_KEY, moved.clone());
|
||||
state.keep(&mut store, false);
|
||||
store.set_string(WINDOW_KEY, MINIMIZED.into());
|
||||
state.keep(&mut store, true);
|
||||
assert_eq!(store.get_string(WINDOW_KEY), Some(moved));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_nothing_shown_yet_a_minimized_save_stores_no_window() {
|
||||
let mut store = Store::default();
|
||||
let mut state = WindowState::started_with(Some(&store));
|
||||
store.set_string(WINDOW_KEY, MINIMIZED.into());
|
||||
state.keep(&mut store, true);
|
||||
assert_eq!(store.get_string(WINDOW_KEY), None, "the next start opens at the default size");
|
||||
}
|
||||
|
||||
fn repaired(size: egui::Vec2, minimized: Option<bool>) -> Vec<egui::ViewportCommand> {
|
||||
let ctx = egui::Context::default();
|
||||
let mut input = egui::RawInput::default();
|
||||
let info = input.viewports.entry(egui::ViewportId::ROOT).or_default();
|
||||
info.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size));
|
||||
info.minimized = minimized;
|
||||
info.monitor_size = Some(egui::vec2(2674.0, 1504.0));
|
||||
let mut state = WindowState { shown: Some(MINIMIZED.into()), checked: false };
|
||||
let out = ctx.run_logic(&input, |ctx| state.repair(ctx));
|
||||
let commands = out.viewport_commands.get(&egui::ViewportId::ROOT).cloned().unwrap_or_default();
|
||||
// Repaired: the settings it started with are not put back by a minimized close.
|
||||
assert_eq!(state.shown.is_none(), !commands.is_empty());
|
||||
commands
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_window_at_eguis_floor_gets_a_usable_size() {
|
||||
assert_eq!(repaired(egui::vec2(64.0, 64.0), Some(false)), [egui::ViewportCommand::InnerSize(USABLE)]);
|
||||
assert!(repaired(egui::vec2(820.0, 520.0), Some(false)).is_empty(), "a small window someone chose stays");
|
||||
assert!(repaired(egui::vec2(64.0, 64.0), Some(true)).is_empty(), "not while minimized");
|
||||
}
|
||||
}
|
||||
@@ -932,6 +932,70 @@ fn an_earlier_revision_opens_from_document_properties() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_revision_buttons_stay_above_binding_and_open_the_saved_revision() {
|
||||
use pdfcraft_engine::{Edit, Session};
|
||||
let mut bytes = std::sync::Arc::new(fixture(1));
|
||||
// Reopen each save so the next edit appends to the preceding saved revision.
|
||||
for revision in 2..=13 {
|
||||
let mut source = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = source.open("revisions.pdf", None, bytes, None).unwrap();
|
||||
source.apply(id, Edit::SetInfo { key: "Title".into(), value: format!("Revision {revision}") }).unwrap();
|
||||
bytes = source.save_bytes(id).unwrap();
|
||||
}
|
||||
for width in [1400.0, 720.0] {
|
||||
let pdf = bytes.clone();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(width, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("revisions.pdf", None, pdf.to_vec()).unwrap();
|
||||
app
|
||||
});
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[s.active.unwrap()].id).unwrap();
|
||||
assert_eq!(doc.revision_ends().len(), 13, "the fixture contains thirteen saved revisions");
|
||||
h.state_mut().dialog = Some(Dialog::Properties(pdfcraft_ui_egui::PropsTab::Advanced));
|
||||
// Let the grid and the centered dialog settle before measuring or clicking.
|
||||
h.run_steps(6);
|
||||
let buttons: Vec<_> = (1..13).map(|n| (n, h.get_by_label(&format!("View revision {n}")).rect())).collect();
|
||||
let first_row = h.get_by_label("View revision 12").rect();
|
||||
let lower_revision = buttons
|
||||
.iter()
|
||||
.find(|(n, rect)| *n > 1 && rect.top() > first_row.bottom())
|
||||
.map(|(n, _)| *n)
|
||||
.expect("revision buttons wrap to a lower row");
|
||||
let binding = h.get_by_label("Binding").rect();
|
||||
let left = h.get_by_label("Left Edge").rect();
|
||||
let right = h.get_by_label("Right Edge").rect();
|
||||
// Properties requests a fixed width. Measure its card in each viewport.
|
||||
let card = h.ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::new("dialog").with("card"))).expect("Properties is shown");
|
||||
for (n, rect) in &buttons {
|
||||
for (label, below) in [("Binding", binding), ("Left Edge", left), ("Right Edge", right)] {
|
||||
assert!(rect.bottom() <= below.top(), "revision {n} overlaps {label} at viewport width {width}: {rect:?}, {below:?}");
|
||||
}
|
||||
}
|
||||
for (n, rect) in &buttons {
|
||||
assert!(
|
||||
rect.left() >= card.left() && rect.right() <= card.right(),
|
||||
"revision {n} escapes Properties horizontally at viewport width {width}: {rect:?}, measured card {card:?}"
|
||||
);
|
||||
}
|
||||
h.get_by_label(&format!("View revision {lower_revision}")).scroll_to_me();
|
||||
h.run_steps(3);
|
||||
h.get_by_label(&format!("View revision {lower_revision}")).click();
|
||||
h.run_steps(3);
|
||||
let s = h.state();
|
||||
assert!(s.dialog.is_none(), "opening a revision closes Properties");
|
||||
let view = &s.views[s.active.unwrap()];
|
||||
let doc = s.session.get(view.id).unwrap();
|
||||
assert_eq!(doc.name, format!("revisions (revision {lower_revision}).pdf"));
|
||||
assert_eq!(doc.info_value("Title"), Some(format!("Revision {lower_revision}")));
|
||||
let snapshot = view.snapshot.as_ref().expect("the saved revision opens as a snapshot");
|
||||
assert_eq!(snapshot.kind, pdfcraft_ui_egui::canvas::SnapshotKind::Revision);
|
||||
assert_eq!(snapshot.revision, lower_revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_search_lists_results_with_context() {
|
||||
let mut h = harness();
|
||||
|
||||
+1
-1
@@ -101,7 +101,7 @@ total matches the parity estimate.
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. Page marks, added content and print output retain small page-unit transform coefficients when serialized. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. Missing: model crate, fonts, DisplayList, renderer independent of hayro. Inspector page-label traversal and output are resource-bounded, with physical-number fallback warnings. Packed TIFF-predictor RGB rows retain later rows in decoding and rendering. The renderer keeps its one-point crop-size floor in physical points after UserUnit scaling, with regression coverage for crop origins, rotations and f32 reciprocal rounding. Existing unit policy and nearly-zero fallback are retained. |
|
||||
| M2 | Model, render, text | 200–350 | 15% | 130–230 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input, and JPX sample rescaling handles 1–32 bits safely. Text extraction reaches word-F1 0.98 against pdftotext. Short same-line redraws split before placement rather than interleaving. 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 | 90% | 10–20 | 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<br>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; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | 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<br>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; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). Selected-text Copy in Edit and the canvas context menu is covered by focused regressions and native clipboard checks (#1225); document copy restrictions apply to the floating action too. #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Page-label navigation displays logical labels, retains the current page on unchanged submission and refreshes focused same-document replacements; clean file-watcher reload preserves the chosen scrolling mode, preventing a Single Page reader from jumping to page 1 during replacement. Synthetic control regressions cover these behaviors. Wrapped revision buttons stay above Binding in Document Properties (#2739); a synthetic UI test checks layout and saved revision content. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | 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<br>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; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. Later native-open replies stay queued while a modal or picker owns the foreground; regressions cover recipient ownership, completion/cancellation and one-shot wakes. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | 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<br>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; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation. #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; Ctrl+Q/Cmd+Q asks before discarding unsaved documents. |
|
||||
| M3 | Viewer app (native + web) | 80–150 | 90% | 10–20 | 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<br>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; numeric Linux portal events and idle wakeups completing #881, with native appearance inheritance retained (#446); late browser startup downloads preserve the current document and Save target (#171). #1364 candidate: absolute local GoToR links ask before target access; headless callers explicitly opt in. Native-opening candidate: chosen tools survive preparation and password retries; startup intake waits for pending preparation.<br>Candidate: coordinate control clicks pair press/release across slow frames; coalesced primary presses retain text multi-click selection. Regression cases cover slow-frame button activation, disabled controls and text multi-click selection; estimates unchanged. |
|
||||
|
||||
@@ -111,6 +111,11 @@
|
||||
Resource 1 is the app's lion; resource 2 is the PDF document (build.rs). -->
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\PdfCraft.Document\DefaultIcon" Value=""[#PdfcraftExe]",-2" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\PdfCraft.Document\shell\open\command" Value=""[#PdfcraftExe]" "%1"" Type="string" />
|
||||
<!-- No overlay on thumbnails and in the preview pane: without TypeOverlay, Windows paints
|
||||
the default program's icon (the lion) over the lower-right corner of every PDF
|
||||
thumbnail, covering part of the page. An empty string means none ("Thumbnail
|
||||
Handlers", Microsoft Learn). -->
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\PdfCraft.Document" Name="TypeOverlay" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\.pdf\OpenWithProgids" Name="PdfCraft.Document" Value="" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\pdfcraft.exe" Name="FriendlyAppName" Value="PdfCraft" Type="string" />
|
||||
<RegistryValue Root="HKLM" Key="Software\Classes\Applications\pdfcraft.exe\DefaultIcon" Value=""[#PdfcraftExe]",-2" Type="string" />
|
||||
|
||||
Reference in New Issue
Block a user