mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 01:11:19 +00:00
Merge remote-tracking branch 'upstream/main' into fix/stream-without-endstream
This commit is contained in:
commit
12cb7c5ce1
40 files changed
+1166
-203
No files matched your search
Generated
+10
@@ -1303,6 +1303,15 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
@@ -4729,6 +4738,7 @@ dependencies = [
|
||||
name = "pdfcraft-web"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"js-sys",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (web: same-document host mode on the canvas, `data-host="page"`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
|
||||
@@ -273,6 +274,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-29 (M2):** Scanned pages look like the paper again. An image that doesn't ask for /Interpolate was shrunk by nearest-neighbour (since #635), which kept one sample in N per axis, so the text of a 300 dpi scan at 100% lost most of its strokes; it is now area-averaged to whole device pixels (on a 13-page 300 dpi scan every sample at 96–192 dpi is within 1 of the exact area average; it was up to 255 off). Enlarged, an image of page-scan size is smoothed bilinearly, so a zoomed scan has no staircases; icons, QR codes and pixel art keep hard pixels. Estimates unchanged.
|
||||
- **2026-10-26 (M11, JBIG2 symbols):** Lossless JBIG2 now stores each distinct mark of a black-and-white image once, as a symbol, and places its exact copies, when that is smaller than the generic region: on six pages of rendered 300 dpi text 28% of the generic region's size and 17% of Group 4's, and the same bits back through two independent decoders (hayro-jbig2 and Poppler). Scans, whose letters are seldom identical, gain little until refinement coding; a dictionary shared between pages (`/JBIG2Globals`) is next. Estimates unchanged.
|
||||
- **2026-10-25 (M5, comment replies):** One reply can be deleted from the Comments panel without its thread. On the selected card each reply has its own "…" menu (Edit, Copy text, Delete); Delete takes that reply with its pop-up and the replies to it, as `comment_delete` already did headlessly, and the comment and its other replies stay. The card stays selected, and the panel's editor stays open, on what they were on though the delete moved it in `/Annots`. Undo and Redo close the editor, since either can move what it was open on. A locked reply can't be deleted. Estimates unchanged.
|
||||
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
|
||||
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
@@ -302,6 +304,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
|
||||
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
|
||||
- **2026-10-24 (M5, Escape in Fill & Sign):** Escape leaves a Fill & Sign tool as it leaves a comment tool: the first press closes the open type box, the next goes back to Select, so the Text tool no longer opens a box at every click with no way out by the keyboard. A synthetic UI regression covers the Text and Checkmark tools. Estimates unchanged.
|
||||
- **2026-10-23 (M5, ortho):** Shift while drawing holds a line or arrow level or plumb, whichever is nearer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before; dragging an end or corner with Shift is held the same way (it turned to 45° steps). `measure::snap::ortho`; `comment_add ortho`. Estimates unchanged.
|
||||
- **2026-10-24 (M3, arrow keys on a wide page):** → / ← scroll a page wider than the window (a plan zoomed in) sideways a line at a time and turn the page at its edge, as ↓ / ↑ do in single-page view; ⌘→ / ⌘← turn it at once, and a page that fits across turns as before (#185). A synthetic viewer regression covers both. Estimates unchanged.
|
||||
- **2026-10-24 (M5, part opacity):** Comment Properties sets a text box's text, border and fill, and a shape's line and fill, each at an opacity of its own (a see-through fill with an opaque line on a plan); stored as /CA, /ca (PDF 2.0) and /PCTextOpacity and drawn with separate graphics states, so other viewers show it. Make Properties Default and the settings carry it; comment_add / comment_edit `fill_opacity`, `text_opacity`. Four new labels in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M4, #3916 partial):** Document edits and Undo/Redo cancel an Organize page drag. Pointer-release regressions check exact page order and save/reopen.
|
||||
- **2026-10-22 (M10, #3752 paper):** CUPS jobs ask for the chosen paper in every size. The 13 sizes the Print dialog sent no paper for (A6, A0–A2, ARCH, ANSI C–E) use PWG 5101.1 names, checked against a synthetic PPD with `cupsfilter`, and `doc_print` to a printer sends the paper too. The macOS tray half (Auto Select instead of the PPD's default tray) is a craft-print change that needs a pin bump here. Estimates unchanged.
|
||||
|
||||
|
||||
@@ -11,10 +11,12 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-ui-egui = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "Location", "Url", "UrlSearchParams", "Response", "Event", "MessageEvent", "EventTarget", "CustomEvent", "CustomEventInit"] }
|
||||
js-sys = "0.3"
|
||||
+81
-110
@@ -5,18 +5,11 @@
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
use eframe::wasm_bindgen::JsCast;
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
let options = eframe::WebOptions { renderer: eframe::Renderer::Glow, ..Default::default() };
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let Some(canvas) = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("pdfcraft"))
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(canvas) = app_canvas() else { return };
|
||||
let started = eframe::WebRunner::new()
|
||||
.start(
|
||||
canvas,
|
||||
@@ -54,11 +47,11 @@ fn main() {
|
||||
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
|
||||
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
|
||||
warn_before_unload(app.unsaved_flag.clone());
|
||||
// `?host=parent`: a same-origin page embedding PdfCraft in an iframe (such
|
||||
// as the Nextcloud app) passes files in and takes saves back (`host` below).
|
||||
if query_param("host").as_deref() == Some("parent")
|
||||
&& let Some(bridge) = host::Bridge::new()
|
||||
{
|
||||
// Host modes (`host` below, over `craft-web-host`): `?host=parent`, a
|
||||
// same-origin page embedding PdfCraft in an iframe (such as the Nextcloud
|
||||
// app), or `data-host="page"` on the canvas, a host that runs the module in
|
||||
// its own document.
|
||||
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
|
||||
// `?author=`: the host's name for the user signs their comments,
|
||||
// unless they chose a name in PdfCraft (the web default is "Guest").
|
||||
if let Some(author) = query_param("author").map(|a| a.trim().to_string()).filter(|a| !a.is_empty())
|
||||
@@ -81,9 +74,7 @@ fn main() {
|
||||
.await;
|
||||
if let Err(e) = started {
|
||||
eframe::web_sys::console::error_1(&e);
|
||||
if query_param("host").as_deref() == Some("parent")
|
||||
&& let Some(bridge) = host::Bridge::new()
|
||||
{
|
||||
if let Some(bridge) = app_canvas().as_ref().and_then(host::Bridge::connect) {
|
||||
bridge.post_failed(&e.as_string().unwrap_or_else(|| format!("{e:?}")));
|
||||
}
|
||||
}
|
||||
@@ -123,136 +114,116 @@ async fn fetch_bytes(url: &str) -> Result<Vec<u8>, String> {
|
||||
Ok(js_sys::Uint8Array::new(&buf).to_vec())
|
||||
}
|
||||
|
||||
/// The `postMessage` bridge to a same-origin parent page. Messages are plain objects with a
|
||||
/// `type`:
|
||||
/// The app's canvas: the one element `index.html` and every embedding page must provide, with
|
||||
/// the id the app looks up.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn app_canvas() -> Option<web_sys::HtmlCanvasElement> {
|
||||
use eframe::wasm_bindgen::JsCast;
|
||||
web_sys::window()?.document()?.get_element_by_id("pdfcraft").and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
}
|
||||
|
||||
/// Host modes: how the page embedding PdfCraft passes files in and takes saves back. The messages
|
||||
/// and the two transports are `craft-web-host`'s (storytold/craft-libs), the same contract as the
|
||||
/// other Craft apps' bridges, with the `pdfcraft:` prefix. This module is PdfCraft's part of it.
|
||||
///
|
||||
/// - `pdfcraft:ready` (to the parent): the app is listening; send files now.
|
||||
/// - `pdfcraft:open` (from the parent): `{ name, bytes }`, where `bytes` is an `ArrayBuffer` or
|
||||
/// `Uint8Array` holding a PDF. It opens in a tab, like a `?file=` URL.
|
||||
/// - `pdfcraft:save` (to the parent): `{ name, saveAs, bytes }` when the user saves; `bytes` is
|
||||
/// a transferred `ArrayBuffer`. The parent stores the file (and reports its own errors).
|
||||
/// - `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. With a parent on
|
||||
/// another origin there is no bridge: the app runs as if unframed and saves download.
|
||||
/// - `?host=parent` is the `postMessage` bridge for a same-origin page that embeds PdfCraft in an
|
||||
/// iframe (such as the Nextcloud app). With a parent on another origin there is no bridge: the
|
||||
/// app runs as if unframed and saves download (#2167). `data-host="page"` on the app's canvas
|
||||
/// is for a host that loads the module into its own document, with no iframe: the same messages
|
||||
/// travel as DOM `CustomEvent`s on that canvas. `?host=parent` wins when both are present.
|
||||
/// - `pdfcraft:ready` once the app is listening; send files now. PdfCraft reads bytes, so it
|
||||
/// says `file: false` and a host sends `bytes`.
|
||||
/// - `pdfcraft:open` (from the host): `{ name, bytes }` holding a PDF. It opens in a tab, like a
|
||||
/// `?file=` URL; a file over [`host::MAX_OPEN_BYTES`] is refused before it is copied.
|
||||
/// - `pdfcraft:save` (to the host): `{ name, saveAs, bytes }` when the user saves. The host stores
|
||||
/// the file (and reports its own errors).
|
||||
/// - `pdfcraft:dirty` whenever unsaved work appears or goes away, `pdfcraft:failed` when the app
|
||||
/// couldn't start.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host {
|
||||
use eframe::wasm_bindgen::{JsCast, JsValue, closure::Closure};
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
|
||||
/// The app id in the host messages (`pdfcraft:ready`).
|
||||
const APP: &str = "pdfcraft";
|
||||
|
||||
/// The name a file gets when the host sent none (the crate says `document` for a blank name).
|
||||
const DEFAULT_NAME: &str = "document.pdf";
|
||||
|
||||
/// The largest file the host may hand over to open, checked before it is copied (the app's own readers have their own limits).
|
||||
const MAX_OPEN_BYTES: u64 = 512 << 20;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
host: Rc<Host>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// `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()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
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 })
|
||||
}
|
||||
|
||||
fn post(&self, message: &js_sys::Object, transfer: Option<&JsValue>) -> Result<(), String> {
|
||||
let sent = match transfer {
|
||||
Some(t) => self.parent.post_message_with_transfer(message, &self.origin, &js_sys::Array::of1(t)),
|
||||
None => self.parent.post_message(message, &self.origin),
|
||||
};
|
||||
sent.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
/// The bridge for this page, or `None` when PdfCraft isn't hosted: no `?host=parent` and
|
||||
/// no `data-host="page"` on the canvas, an unframed `?host=parent` page, or a parent 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 connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?) })
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
if let Some(m) = message(&[("type", "pdfcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]) {
|
||||
let _ = self.post(&m, None);
|
||||
}
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGL, for example): let the parent say so.
|
||||
/// The app couldn't start (no WebGL, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
if let Some(m) = message(&[("type", "pdfcraft:failed".into()), ("error", error.into())]) {
|
||||
let _ = self.post(&m, None);
|
||||
}
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// Hear `pdfcraft:open` from the parent and queue the file like a `?file=` download.
|
||||
/// Hear `pdfcraft:open` and queue the file like a `?file=` download.
|
||||
pub fn listen(&self, inbox: pdfcraft_ui_egui::Inbox, ctx: egui::Context) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
queue_open(&name, &data, &inbox, &ctx);
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("pdfcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| "document.pdf".to_string());
|
||||
let Ok(bytes) = js_sys::Reflect::get(&data, &JsValue::from_str("bytes")) else { return };
|
||||
let bytes = if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
array.to_vec()
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
js_sys::Uint8Array::new(&bytes).to_vec()
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
if let Ok(mut q) = inbox.lock() {
|
||||
q.push((name, bytes));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_fn(&self) -> pdfcraft_ui_egui::HostSaveFn {
|
||||
let bridge = self.clone();
|
||||
Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let m =
|
||||
message(&[("type", "pdfcraft:save".into()), ("name", name.into()), ("saveAs", save_as.into()), ("bytes", buffer.clone().into())])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
bridge.post(&m, Some(&buffer))
|
||||
})
|
||||
let host = self.host.clone();
|
||||
Box::new(move |name: &str, bytes: &[u8], save_as: bool| host.post_save(name, bytes, None, save_as))
|
||||
}
|
||||
|
||||
pub fn dirty_fn(&self) -> pdfcraft_ui_egui::HostDirtyFn {
|
||||
let bridge = self.clone();
|
||||
let host = self.host.clone();
|
||||
// A host starts out clean: the first report is a change only when it is dirty.
|
||||
let mut last = false;
|
||||
Box::new(move |dirty: bool| {
|
||||
if dirty != last {
|
||||
last = dirty;
|
||||
if let Some(m) = message(&[("type", "pdfcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
let _ = bridge.post(&m, None);
|
||||
}
|
||||
host.post_dirty(dirty);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
/// The `pdfcraft:open` payload, `{ name, bytes }` where `bytes` holds a PDF: queue it like a
|
||||
/// `?file=` download and wake the app. The crate has cleaned the name (and cut it to 255
|
||||
/// characters); a blank one comes as `document` and becomes `document.pdf`.
|
||||
fn queue_open(name: &str, data: &FileData, inbox: &pdfcraft_ui_egui::Inbox, ctx: &egui::Context) {
|
||||
let name = if name == "document" { DEFAULT_NAME } else { name }.to_string();
|
||||
let FileData::Bytes(view) = data else {
|
||||
eframe::web_sys::console::warn_1(&format!("PdfCraft: host file {name} came as a File; PdfCraft reads bytes only; not opened").into());
|
||||
return;
|
||||
};
|
||||
// A file from the host is untrusted input: refuse an oversized one before copying it into the app's memory.
|
||||
if data.size() > MAX_OPEN_BYTES as f64 {
|
||||
eframe::web_sys::console::warn_1(
|
||||
&format!("PdfCraft: host file is {} bytes, over the {} MiB host open limit; not opened", data.size(), MAX_OPEN_BYTES >> 20).into(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
if let Ok(mut q) = inbox.lock() {
|
||||
q.push((name, view.to_vec()));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -183,3 +183,6 @@ sync_github_name = true
|
||||
|
||||
[people.Combiler01]
|
||||
sync_github_name = true
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
@@ -262,19 +262,47 @@ pub fn parse_da(d: &Dict) -> (Rgb, f64) {
|
||||
(col.map(|x| x.clamp(0.0, 1.0)), size.min(400.0))
|
||||
}
|
||||
|
||||
fn ext_gstate(opacity: f64, multiply: bool) -> Option<Dict> {
|
||||
if opacity >= 1.0 && !multiply {
|
||||
/// How opaque a comment's parts are drawn, 0–1: its line or border (`/CA`); its fill or
|
||||
/// background (`/ca`, which PDF 2.0 gives the non-stroking operations; without it, as the line);
|
||||
/// and a text box's text (`/PCTextOpacity`, PdfCraft's own; without it, as the fill, since text
|
||||
/// is filled too). A comment with only `/CA` is drawn at that opacity throughout, as before.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PartOpacity {
|
||||
pub line: f64,
|
||||
pub fill: f64,
|
||||
pub text: f64,
|
||||
}
|
||||
|
||||
/// The parts' opacities of the comment `d` (see [`PartOpacity`]).
|
||||
pub fn part_opacity(d: &Dict) -> PartOpacity {
|
||||
let get = |key: &[u8]| d.get(key).and_then(|o| o.as_f64()).filter(|x| x.is_finite()).map(|x| x.clamp(0.0, 1.0));
|
||||
let line = get(b"CA").unwrap_or(1.0);
|
||||
let fill = get(b"ca").unwrap_or(line);
|
||||
let text = get(b"PCTextOpacity").unwrap_or(fill);
|
||||
PartOpacity { line, fill, text }
|
||||
}
|
||||
|
||||
/// `/GS0`: the line's opacity for strokes and the fill's for everything filled; `/GS1`, where the
|
||||
/// text's differs from the fill's: the text's for its glyphs.
|
||||
fn ext_gstate(parts: PartOpacity, multiply: bool) -> Option<Dict> {
|
||||
if parts.line >= 1.0 && parts.fill >= 1.0 && parts.text >= 1.0 && !multiply {
|
||||
return None;
|
||||
}
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
if multiply {
|
||||
gs.set(b"BM".to_vec(), Object::name("Multiply"));
|
||||
}
|
||||
gs.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
gs.set(b"ca".to_vec(), Object::Real(opacity));
|
||||
let gs = |stroke: f64, fill: f64| {
|
||||
let mut gs = Dict::new();
|
||||
gs.set(b"Type".to_vec(), Object::name("ExtGState"));
|
||||
if multiply {
|
||||
gs.set(b"BM".to_vec(), Object::name("Multiply"));
|
||||
}
|
||||
gs.set(b"CA".to_vec(), Object::Real(stroke));
|
||||
gs.set(b"ca".to_vec(), Object::Real(fill));
|
||||
Object::Dict(gs)
|
||||
};
|
||||
let mut res = Dict::new();
|
||||
res.set(b"GS0".to_vec(), Object::Dict(gs));
|
||||
res.set(b"GS0".to_vec(), gs(parts.line, parts.fill));
|
||||
if parts.text != parts.fill {
|
||||
res.set(b"GS1".to_vec(), gs(parts.line, parts.text));
|
||||
}
|
||||
Some(res)
|
||||
}
|
||||
|
||||
@@ -391,7 +419,9 @@ struct AppearanceContext<'a> {
|
||||
subtype: &'a [u8],
|
||||
rect: [f64; 4],
|
||||
cloudy: bool,
|
||||
/// The line's opacity (`parts.line`).
|
||||
opacity: f64,
|
||||
parts: PartOpacity,
|
||||
stroke: Option<Rgb>,
|
||||
width: f64,
|
||||
}
|
||||
@@ -408,7 +438,7 @@ impl AppearanceDrawing {
|
||||
fn new(context: &AppearanceContext<'_>) -> Self {
|
||||
let mut drawing = Self::default();
|
||||
if let Some(gs) = ext_gstate(
|
||||
context.opacity,
|
||||
context.parts,
|
||||
context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")),
|
||||
) {
|
||||
drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
@@ -455,7 +485,8 @@ fn appearance_context(d: &Dict) -> Option<AppearanceContext<'_>> {
|
||||
subtype,
|
||||
rect,
|
||||
cloudy,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0),
|
||||
opacity: part_opacity(d).line,
|
||||
parts: part_opacity(d),
|
||||
stroke: color(d, b"C")?,
|
||||
width: border_width(d),
|
||||
})
|
||||
@@ -904,6 +935,7 @@ fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -
|
||||
|
||||
fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option<Stream> {
|
||||
let d = context.dict;
|
||||
let parts = context.parts;
|
||||
let rect = context.rect;
|
||||
let stroke = context.stroke;
|
||||
let mut c = std::mem::take(&mut drawing.content);
|
||||
@@ -977,6 +1009,10 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin
|
||||
let pad = 2.0 + bw;
|
||||
let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0);
|
||||
let q = d.int(b"Q").unwrap_or(0);
|
||||
// The text at its own opacity, where it has one.
|
||||
if parts.text != parts.fill {
|
||||
c.push_str("/GS1 gs\n");
|
||||
}
|
||||
c.push_str(&format!(
|
||||
"{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}",
|
||||
n(rect[0]),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
|
||||
use super::{Dict, Object, PartOpacity, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi};
|
||||
|
||||
/// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp
|
||||
/// another program wrote without an appearance. Names are matched byte for byte, as PDF names
|
||||
@@ -51,7 +51,7 @@ pub(crate) fn display_stamp(d: &Dict) -> Option<Stream> {
|
||||
// A malformed `/C` falls back to the stamp's own colour rather than hiding the stamp.
|
||||
let stroke = color(d, b"C").flatten();
|
||||
let mut res = Dict::new();
|
||||
if let Some(gs) = ext_gstate(opacity, false) {
|
||||
if let Some(gs) = ext_gstate(PartOpacity { line: opacity, fill: opacity, text: opacity }, false) {
|
||||
res.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
}
|
||||
// A stamp whose intent (`/IT`, PDF 2.0) is an image or a snapshot has no `/Name` to default.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::draw::draw_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
|
||||
use super::modify::{n, rich_text};
|
||||
use super::modify::{n, rich_text, set_part_keys};
|
||||
use super::types::{
|
||||
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
|
||||
};
|
||||
@@ -214,8 +214,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
let rect = rect_for(&new.shape, style)?;
|
||||
let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta);
|
||||
d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape)));
|
||||
let opacity = style.opacity.clamp(0.0, 1.0);
|
||||
set_non_opaque_style(&mut d, opacity);
|
||||
if style.fill_opacity.into_iter().chain(style.text_opacity).any(|o| !o.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid opacity".into()));
|
||||
}
|
||||
let line = style.opacity.clamp(0.0, 1.0);
|
||||
let fill = style.fill_opacity.map_or(line, |o| o.clamp(0.0, 1.0));
|
||||
let text = style.text_opacity.map_or(fill, |o| o.clamp(0.0, 1.0));
|
||||
set_part_keys(&mut d, appearance::PartOpacity { line, fill, text });
|
||||
let border = |d: &mut Dict| {
|
||||
let mut bs = Dict::new();
|
||||
bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0)));
|
||||
@@ -443,12 +448,6 @@ fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]],
|
||||
}
|
||||
}
|
||||
|
||||
fn set_non_opaque_style(dict: &mut Dict, opacity: f64) {
|
||||
if opacity < 1.0 {
|
||||
dict.set(b"CA".to_vec(), Object::Real(opacity));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec<Object>) -> Result<(), AnnotError> {
|
||||
let Some(rect) = rect else { return Ok(()) };
|
||||
let mut popup = Dict::new();
|
||||
|
||||
@@ -217,6 +217,11 @@ pub struct Props {
|
||||
pub fill: Option<Rgb>,
|
||||
/// Text boxes, typewriter text and callouts: the `/DA` font size in points.
|
||||
pub font_size: Option<f64>,
|
||||
/// How opaque the fill is drawn, for a comment with a fill control (`opacity` is then its
|
||||
/// line's or border's).
|
||||
pub fill_opacity: Option<f64>,
|
||||
/// How opaque a text box's, typewriter text's or callout's text is drawn.
|
||||
pub text_opacity: Option<f64>,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
@@ -239,7 +244,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
author: text_value(doc, d, b"T").unwrap_or_default(),
|
||||
subject: text_value(doc, d, b"Subj").unwrap_or_default(),
|
||||
color,
|
||||
opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0),
|
||||
opacity: appearance::part_opacity(d).line,
|
||||
width,
|
||||
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
|
||||
modified: text_value(doc, d, b"M"),
|
||||
@@ -248,6 +253,8 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
fillable: fillable(&subtype),
|
||||
fill: fill_of(d),
|
||||
font_size: (subtype == "FreeText").then(|| appearance::parse_da(d).1),
|
||||
fill_opacity: fillable(&subtype).then(|| appearance::part_opacity(d).fill),
|
||||
text_opacity: (subtype == "FreeText").then(|| appearance::part_opacity(d).text),
|
||||
subtype,
|
||||
endings: line_endings_of(d),
|
||||
})
|
||||
|
||||
@@ -42,7 +42,7 @@ pub use model3d::{Model3d, Model3dKind, model_3d, model_3d_annotations};
|
||||
pub(crate) use modify::n;
|
||||
pub use modify::{
|
||||
add_reply, add_text_replacement, deletable, delete_annotation, erase_ink, fillable, goes_with, lift_annotation, move_annotation, set_contents,
|
||||
set_fill, set_font_size, set_locked, set_marked, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
set_fill, set_font_size, set_locked, set_marked, set_part_opacity, set_points, set_rect, set_review_state, set_style, with_replies_and_popups,
|
||||
};
|
||||
pub use threed::{Activation as Activation3d, MAX_MODEL_BYTES, ModelFormat, NewModel3d, PosterPicture, add_3d_model, check_model};
|
||||
pub use types::{
|
||||
|
||||
@@ -558,7 +558,8 @@ fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
|
||||
Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half])
|
||||
}
|
||||
|
||||
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
|
||||
/// Change a comment's colour, opacity (of the whole comment), line width and/or line endings, and
|
||||
/// redraw it.
|
||||
pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let StyleChange { color, opacity, width, endings } = change;
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
@@ -593,13 +594,10 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
|
||||
d.set(b"C".to_vec(), rgb(c));
|
||||
}
|
||||
}
|
||||
// The whole comment: its fill and text drop opacities of their own.
|
||||
if let Some(o) = opacity {
|
||||
let o = o.clamp(0.0, 1.0);
|
||||
if o < 1.0 {
|
||||
d.set(b"CA".to_vec(), Object::Real(o));
|
||||
} else {
|
||||
d.remove(b"CA");
|
||||
}
|
||||
set_part_keys(d, appearance::PartOpacity { line: o, fill: o, text: o });
|
||||
}
|
||||
if let Some(w) = width {
|
||||
let old_w = border_width_of(d);
|
||||
@@ -707,6 +705,63 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an
|
||||
/// opacity of its own (0–1), its line or border keeping the comment's (`/CA`), and redraw it.
|
||||
/// `None` leaves that part as it is drawn now. The fill goes in `/ca` (PDF 2.0's opacity for
|
||||
/// what is filled), the text in `/PCTextOpacity`, each only where it differs from the one it
|
||||
/// would otherwise follow.
|
||||
pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Option<f64>, text: Option<f64>, meta: &Meta) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
unlocked(doc, r)?;
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
if fill.is_some() && !fillable(&subtype) {
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes have a fill of their own".into()));
|
||||
}
|
||||
if text.is_some() && subtype != "FreeText" {
|
||||
return Err(AnnotError::Invalid("only text boxes, typewriter text and callouts have text of their own".into()));
|
||||
}
|
||||
if fill.into_iter().chain(text).any(|o| !o.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid opacity".into()));
|
||||
}
|
||||
// Check before changing anything: a stale appearance would contradict the new opacity.
|
||||
if appearance::build(&d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
let before = d;
|
||||
doc.update_dict(r, |d| {
|
||||
let mut parts = appearance::part_opacity(d);
|
||||
if let Some(f) = fill {
|
||||
parts.fill = f.clamp(0.0, 1.0);
|
||||
}
|
||||
if let Some(t) = text {
|
||||
parts.text = t.clamp(0.0, 1.0);
|
||||
}
|
||||
set_part_keys(d, parts);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
redraw_or_restore(doc, r, before)
|
||||
}
|
||||
|
||||
/// Write a comment's parts' opacities (see [`appearance::PartOpacity`]): the line's as `/CA`, the
|
||||
/// fill's as `/ca` and the text's as `/PCTextOpacity`, each only where it differs from the one
|
||||
/// it would otherwise follow (the fill the line, the text the fill), so a comment drawn at one
|
||||
/// opacity keeps just `/CA`.
|
||||
pub(crate) fn set_part_keys(d: &mut Dict, parts: appearance::PartOpacity) {
|
||||
let appearance::PartOpacity { line, fill, text } = parts;
|
||||
let mut put = |key: &[u8], value: f64, unless: f64| {
|
||||
if value == unless {
|
||||
d.remove(key);
|
||||
} else {
|
||||
d.set(key.to_vec(), Object::Real(value));
|
||||
}
|
||||
};
|
||||
put(b"CA", line, 1.0);
|
||||
put(b"ca", fill, line);
|
||||
put(b"PCTextOpacity", text, fill);
|
||||
}
|
||||
|
||||
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
|
||||
fn ensure_ending_room(d: &mut Dict) {
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
|
||||
@@ -40,7 +40,7 @@ fn new(page: usize, shape: Shape) -> NewAnnotation {
|
||||
NewAnnotation {
|
||||
page,
|
||||
shape,
|
||||
style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None },
|
||||
style: Style { color: [0.0, 0.4, 1.0], opacity: 1.0, width: 2.0, fill: None, ..Style::default() },
|
||||
contents: "hello".into(),
|
||||
author: "Ada".into(),
|
||||
}
|
||||
@@ -356,6 +356,83 @@ fn restyle_and_resize_redraw_the_appearance() {
|
||||
assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
/// A rectangle's fill, and a text box's border, background and text, each drawn at an opacity of
|
||||
/// its own: the line's in `/CA`, the fill's in `/ca` (PDF 2.0), the text's in `/PCTextOpacity`, and
|
||||
/// in the appearance `/GS0` (strokes at the line's, fills at the fill's) and `/GS1` for the text.
|
||||
#[test]
|
||||
fn parts_are_drawn_at_opacities_of_their_own() {
|
||||
let num = |d: &Dict, k: &[u8]| d.get(k).and_then(|o| o.as_f64());
|
||||
let gstate = |doc: &Document, d: &Dict, name: &[u8]| -> Option<(f64, f64)> {
|
||||
let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N")))?;
|
||||
let obj = doc.get(n);
|
||||
let Object::Stream(s) = &*obj else { return None };
|
||||
let res = s.dict.get(b"Resources").map(|r| doc.resolve(r))?;
|
||||
let gs = res.as_dict()?.get(b"ExtGState").map(|g| doc.resolve(g))?;
|
||||
let gs = gs.as_dict()?.get(name).map(|g| doc.resolve(g))?;
|
||||
let gs = gs.as_dict()?;
|
||||
Some((gs.get(b"CA")?.as_f64()?, gs.get(b"ca")?.as_f64()?))
|
||||
};
|
||||
let mut doc = fixture();
|
||||
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
|
||||
set_fill(&mut doc, 0, r, Some([1.0, 1.0, 0.0]), &meta("")).unwrap();
|
||||
set_part_opacity(&mut doc, 0, r, Some(0.3), None, &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[r];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca")), (None, Some(0.3)), "an opaque line, a fill of its own");
|
||||
assert_eq!(gstate(&doc2, d, b"GS0"), Some((1.0, 0.3)));
|
||||
let ap = ap_content(&doc2, d);
|
||||
assert!(ap.starts_with("/GS0 gs") && ap.contains("\nB\n"), "filled and stroked in one go: {ap}");
|
||||
let p = props(&doc2, 0, r).unwrap();
|
||||
assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.3), None));
|
||||
// The whole comment's opacity (StyleAnnotation, the quick bar): the fill goes along again.
|
||||
set_style(&mut doc, 0, r, StyleChange::new(None, Some(0.6), None, None), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[r];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca")), (Some(0.6), None));
|
||||
assert_eq!(props(&doc, 0, r).unwrap().fill_opacity, Some(0.6));
|
||||
|
||||
// A text box with its border opaque, its background at 20 % and its text opaque, as made.
|
||||
let mut tb = new(0, Shape::TextBox { rect: [200.0, 600.0, 400.0, 650.0], font_size: 12.0 });
|
||||
tb.style.fill = Some([1.0, 1.0, 0.6]);
|
||||
tb.style.width = 1.0;
|
||||
(tb.style.fill_opacity, tb.style.text_opacity) = (Some(0.2), Some(1.0));
|
||||
let t = add_annotation(&mut doc, &tb, &meta("t")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[t];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, Some(0.2), Some(1.0)));
|
||||
assert_eq!((gstate(&doc2, d, b"GS0"), gstate(&doc2, d, b"GS1")), (Some((1.0, 0.2)), Some((1.0, 1.0))));
|
||||
let ap = ap_content(&doc2, d);
|
||||
let (gs1, text) = (ap.find("/GS1 gs\n").expect("the text's own state, an operator of its own"), ap.find("BT").expect("text"));
|
||||
assert!(ap.find(" re f").is_some_and(|fill| fill < gs1) && gs1 < text, "background at 20 %, then the text at its own: {ap}");
|
||||
// A new fill opacity leaves the text as it is drawn.
|
||||
set_part_opacity(&mut doc, 0, t, Some(0.5), None, &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!((num(d, b"ca"), num(d, b"PCTextOpacity")), (Some(0.5), Some(1.0)));
|
||||
let p = props(&doc, 0, t).unwrap();
|
||||
assert_eq!((p.opacity, p.fill_opacity, p.text_opacity), (1.0, Some(0.5), Some(1.0)));
|
||||
// All at one opacity again: only /CA stays.
|
||||
set_part_opacity(&mut doc, 0, t, Some(1.0), Some(1.0), &meta("")).unwrap();
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!((num(d, b"CA"), num(d, b"ca"), num(d, b"PCTextOpacity")), (None, None, None));
|
||||
|
||||
// Only the parts a comment has, and finite values; nothing changes when refused.
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, l, Some(0.5), None, &meta("")), Err(AnnotError::Invalid(_))), "a line has no fill");
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, r, None, Some(0.5), &meta("")), Err(AnnotError::Invalid(_))), "a rectangle has no text");
|
||||
let before = list(&doc, 0)[r].clone();
|
||||
assert!(matches!(set_part_opacity(&mut doc, 0, r, Some(f64::NAN), None, &meta("")), Err(AnnotError::Invalid(_))));
|
||||
assert_eq!(list(&doc, 0)[r], before);
|
||||
// Another app's comment with /CA alone is drawn at it throughout, as before.
|
||||
assert_eq!(
|
||||
appearance::part_opacity(&Dict::from_iter([(b"CA".to_vec(), Object::Real(0.4))])),
|
||||
appearance::PartOpacity { line: 0.4, fill: 0.4, text: 0.4 }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_can_be_filled_and_unfilled() {
|
||||
let mut doc = fixture();
|
||||
@@ -873,8 +950,8 @@ fn cloud_ys(path: &str) -> Vec<f64> {
|
||||
fn replace_text_groups_a_strikeout_and_a_caret() {
|
||||
let mut doc = fixture();
|
||||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None };
|
||||
let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let i = add_text_replacement(
|
||||
&mut doc,
|
||||
TextReplacement {
|
||||
|
||||
@@ -566,6 +566,11 @@ pub struct Style {
|
||||
pub width: f64,
|
||||
/// Interior fill for rectangles and ovals (`/IC`), background for text boxes.
|
||||
pub fill: Option<Rgb>,
|
||||
/// The fill's own opacity, 0–1 (`/ca`), where it differs from `opacity` (which is then the
|
||||
/// line's or border's); `None`: as `opacity`.
|
||||
pub fill_opacity: Option<f64>,
|
||||
/// A text box's text's own opacity, 0–1 (`/PCTextOpacity`); `None`: as the fill's.
|
||||
pub text_opacity: Option<f64>,
|
||||
}
|
||||
|
||||
/// The style properties changed by one annotation edit. `None` leaves a property unchanged.
|
||||
@@ -597,7 +602,7 @@ pub struct TextReplacement<'a> {
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None }
|
||||
Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None, fill_opacity: None, text_opacity: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -615,7 +620,7 @@ impl Style {
|
||||
}
|
||||
Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0),
|
||||
Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0),
|
||||
Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None },
|
||||
Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, ..Self::default() },
|
||||
Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0),
|
||||
Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5),
|
||||
@@ -624,9 +629,9 @@ impl Style {
|
||||
Shape::CustomStamp { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::TypedSignature { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
// Red outline while marked; a black box once applied.
|
||||
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: Some([0.0, 0.0, 0.0]) },
|
||||
Shape::Redact { .. } => return Self { color: [0.89, 0.13, 0.13], fill: Some([0.0, 0.0, 0.0]), ..Self::default() },
|
||||
};
|
||||
Self { color, opacity: 1.0, width, fill: None }
|
||||
Self { color, width, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,6 +190,19 @@ impl Automation {
|
||||
"rect": crate::view_rect(&doc.info, c.page, c.shown_rect(doc.info.pages.get(c.page).map_or(0, |p| p.rotation))),
|
||||
"color": c.color.map(hex),
|
||||
});
|
||||
// How opaque it is drawn: as a whole, or its fill and text each at their own.
|
||||
// Not for replies, which are drawn with the comment they answer.
|
||||
if !c.is_reply()
|
||||
&& let Some(p) = doc.comment_props(c.page, c.index)
|
||||
{
|
||||
v["opacity"] = json!(p.opacity);
|
||||
if let Some(f) = p.fill_opacity {
|
||||
v["fill_opacity"] = json!(f);
|
||||
}
|
||||
if let Some(t) = p.text_opacity {
|
||||
v["text_opacity"] = json!(t);
|
||||
}
|
||||
}
|
||||
// A line's ends, or the corners of connected lines, a polygon or a cloud, as
|
||||
// comment_edit's `points` takes them.
|
||||
// Unchanged on a page the viewer could not read, as `rect` is.
|
||||
@@ -253,6 +266,12 @@ impl Automation {
|
||||
if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") {
|
||||
return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into()));
|
||||
}
|
||||
// Shift while drawing: each end or corner held level or plumb with the one before.
|
||||
let ortho = a.opt_bool("ortho")?.unwrap_or(false);
|
||||
if ortho && !matches!(kind, "line" | "arrow" | "polygon" | "cloud" | "polyline") {
|
||||
return Err(ToolError::InvalidArgs("ortho applies to a line, arrow, polygon, cloud or polyline".into()));
|
||||
}
|
||||
let held = |last: [f64; 2], p: [f64; 2]| if ortho { pdfcraft_engine::measure::snap::ortho(last, p) } else { p };
|
||||
let markup = match kind {
|
||||
"highlight" => Some(Markup::Highlight),
|
||||
"underline" => Some(Markup::Underline),
|
||||
@@ -325,7 +344,8 @@ impl Automation {
|
||||
Shape::TextBox { rect: rect_to_user(&info, a.need::<4>("rect", "a text box")?), font_size }
|
||||
}
|
||||
"line" | "arrow" => {
|
||||
let (f, t) = (a.need::<2>("from", "a line")?, a.need::<2>("to", "a line")?);
|
||||
let f = a.need::<2>("from", "a line")?;
|
||||
let t = held(f, a.need::<2>("to", "a line")?);
|
||||
let [start, end] = ending_pair(kind, endings.as_deref())?;
|
||||
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end }
|
||||
}
|
||||
@@ -352,10 +372,15 @@ impl Automation {
|
||||
"polygon" | "cloud" | "polyline" => {
|
||||
let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points"));
|
||||
let pts = a.get("points").and_then(|p| p.as_array()).ok_or_else(wrong)?;
|
||||
let mut last = None;
|
||||
let vertices = pts
|
||||
.iter()
|
||||
.map(|p| match p.as_array().map(|p| p.iter().map(|v| v.as_f64()).collect::<Vec<_>>()).as_deref() {
|
||||
Some([Some(x), Some(y)]) => Ok(to_user(&info, *x, *y)),
|
||||
Some([Some(x), Some(y)]) => {
|
||||
let p = last.map_or([*x, *y], |l| held(l, [*x, *y]));
|
||||
last = Some(p);
|
||||
Ok(to_user(&info, p[0], p[1]))
|
||||
}
|
||||
_ => Err(wrong()),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
@@ -425,6 +450,19 @@ impl Automation {
|
||||
if let Some(o) = a.opt_num("opacity")? {
|
||||
style.opacity = o.clamp(0.0, 1.0);
|
||||
}
|
||||
// The fill's and a text box's text's own opacities (the line keeps `opacity`).
|
||||
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
|
||||
let texts = matches!(shape, Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. });
|
||||
if fill_opacity.is_some() && !(texts || matches!(shape, Shape::Rectangle { .. } | Shape::Oval { .. } | Shape::Polygon { .. })) {
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"fill_opacity applies to a rectangle, oval, polygon, cloud, text box, typewriter text or callout".into(),
|
||||
));
|
||||
}
|
||||
if text_opacity.is_some() && !texts {
|
||||
return Err(ToolError::InvalidArgs("text_opacity applies to a text box, typewriter text or callout".into()));
|
||||
}
|
||||
style.fill_opacity = fill_opacity.map(|o| o.clamp(0.0, 1.0));
|
||||
style.text_opacity = text_opacity.map(|o| o.clamp(0.0, 1.0));
|
||||
if let Some(w) = a.opt_num("width")? {
|
||||
style.width = w.max(0.0);
|
||||
}
|
||||
@@ -656,6 +694,14 @@ impl Automation {
|
||||
let fill = if f.eq_ignore_ascii_case("none") { None } else { Some(parse_color(f)?) };
|
||||
edits.push(Edit::FillAnnotation { page, index, fill });
|
||||
}
|
||||
// After `opacity`, which sets the whole comment's.
|
||||
let (fill_opacity, text_opacity) = (a.opt_num("fill_opacity")?, a.opt_num("text_opacity")?);
|
||||
if fill_opacity.into_iter().chain(text_opacity).any(|o| !(0.0..=1.0).contains(&o)) {
|
||||
return Err(ToolError::InvalidArgs("fill_opacity and text_opacity are 0–1".into()));
|
||||
}
|
||||
if fill_opacity.is_some() || text_opacity.is_some() {
|
||||
edits.push(Edit::PartOpacity { page, index, fill: fill_opacity, text: text_opacity });
|
||||
}
|
||||
if let Some(size) = a.opt_num("font_size")? {
|
||||
if !(1.0..=400.0).contains(&size) {
|
||||
return Err(ToolError::InvalidArgs("font_size must be 1–400 pt".into()));
|
||||
@@ -689,7 +735,7 @@ impl Automation {
|
||||
}
|
||||
if edits.is_empty() {
|
||||
return Err(ToolError::InvalidArgs(
|
||||
"nothing to change: pass contents, color, opacity, width, fill, font_size, rect, move or points".into(),
|
||||
"nothing to change: pass contents, color, opacity, fill_opacity, text_opacity, width, fill, font_size, rect, move or points".into(),
|
||||
));
|
||||
}
|
||||
let edit = if edits.len() == 1 { edits.remove(0) } else { Edit::Batch { label: "Edit comment".into(), edits } };
|
||||
|
||||
@@ -858,9 +858,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"icon": { "type": "string", "description": "note: Comment, Note, Help, Insert, Key, NewParagraph, Paragraph (the standard names), Check, Circle, Cross, CrossHairs, Star, RightArrow, UpArrow; attachment: PushPin, Paperclip, Graph, Tag." },
|
||||
"color": color(),
|
||||
"fill": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity (its line or border's, where its fill or text has its own)." },
|
||||
"fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity: rectangle, oval, polygon, cloud, text box, typewriter text or callout." },
|
||||
"text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity: text box, typewriter text or callout." },
|
||||
"width": { "type": "number", "minimum": 0, "description": "Line width in points." },
|
||||
"endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." },
|
||||
"ortho": { "type": "boolean", "description": "line/arrow/polygon/cloud/polyline: hold each end or corner level or plumb with the one before, whichever is nearer (Shift while drawing)." },
|
||||
"font_size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
}),
|
||||
&["doc", "page", "type"],
|
||||
@@ -991,11 +994,13 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "sort": { "type": "string", "enum": ["page", "author", "date", "type"] }, "out": save_out, "open": open }),
|
||||
&["doc"],
|
||||
)),
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity, line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
t("comment_edit", "Edit a comment", "Change a comment's text, colour, opacity (the whole comment's, or its fill's and text's own), line width, font size (text box/typewriter/callout), fill (rectangle/oval/polygon/text box/callout; \"none\" removes it), rectangle (rectangle/oval/text box/stamp), position (`move` [dx, dy] in points) or shape (`points`: a line's two ends, or the corners of connected lines, a polygon or a cloud). Stamps keep their original appearance when resized. One undo step.").with(schema(
|
||||
comment_ref(json!({
|
||||
"contents": { "type": "string" },
|
||||
"color": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The whole comment's opacity: its fill and text drop opacities of their own." },
|
||||
"fill_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The fill's own opacity (rectangle, oval, polygon, cloud, text box, typewriter text, callout); the line or border keeps the comment's." },
|
||||
"text_opacity": { "type": "number", "minimum": 0, "maximum": 1, "description": "The text's own opacity (text box, typewriter text, callout)." },
|
||||
"width": { "type": "number", "minimum": 0 },
|
||||
"fill": { "type": "string", "description": "Interior colour of a rectangle, oval, polygon, text box or callout: #RRGGBB, a colour name, or \"none\" for no fill." },
|
||||
"font_size": { "type": "number", "minimum": 1, "maximum": 400, "description": "Font size in points of a text box, typewriter text or callout; the box grows to hold the text." },
|
||||
|
||||
@@ -2135,6 +2135,55 @@ fn numbering_pages_through_tools() {
|
||||
assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// A rectangle's fill and a text box's text each take an opacity of their own through the tools
|
||||
/// (the line or border keeps the comment's `opacity`); `opacity` sets the whole comment's again.
|
||||
#[test]
|
||||
fn comment_parts_take_opacities_of_their_own_through_the_tools() {
|
||||
let dir = workdir("part-opacity");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "fill": "yellow", "fill_opacity": 0.25 }),
|
||||
);
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "fill": "#FFFF99", "width": 1, "fill_opacity": 0.2, "text_opacity": 1 }),
|
||||
);
|
||||
let find = |a: &mut Automation, kind: &str| {
|
||||
let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
list["comments"].as_array().unwrap().iter().find(|c| c["type"] == kind).unwrap().clone()
|
||||
};
|
||||
let rect = find(&mut a, "Square");
|
||||
assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64(), rect.get("text_opacity")), (Some(1.0), Some(0.25), None));
|
||||
let tb = find(&mut a, "FreeText");
|
||||
assert_eq!((tb["opacity"].as_f64(), tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(1.0), Some(0.2), Some(1.0)));
|
||||
// The fill changes, the text stays; an undo step.
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "fill_opacity": 0.5 }));
|
||||
let tb = find(&mut a, "FreeText");
|
||||
assert_eq!((tb["fill_opacity"].as_f64(), tb["text_opacity"].as_f64()), (Some(0.5), Some(1.0)));
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change comment properties");
|
||||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||||
// The whole comment's opacity: everything at it again.
|
||||
ok(&mut a, "comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "opacity": 0.6 }));
|
||||
let rect = find(&mut a, "Square");
|
||||
assert_eq!((rect["opacity"].as_f64(), rect["fill_opacity"].as_f64()), (Some(0.6), Some(0.6)));
|
||||
// Only the parts a comment has, at 0–1.
|
||||
for (tool, args) in [
|
||||
("comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [0, 0], "to": [50, 50], "fill_opacity": 0.5 })),
|
||||
("comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [80, 10, 120, 40], "text_opacity": 0.5 })),
|
||||
("comment_edit", json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "fill_opacity": 2 })),
|
||||
] {
|
||||
assert!(matches!(a.call(tool, &args), Err(ToolError::InvalidArgs(_))), "{args}");
|
||||
}
|
||||
assert!(matches!(
|
||||
a.call("comment_edit", &json!({ "doc": doc, "page": rect["page"], "index": rect["index"], "text_opacity": 0.5 })),
|
||||
Err(ToolError::Failed(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_through_tools() {
|
||||
let dir = workdir("comments");
|
||||
@@ -6493,6 +6542,44 @@ fn drawing_comments_through_tools() {
|
||||
assert!(matches!(png[0], Content::Png { .. }));
|
||||
}
|
||||
|
||||
/// comment_add `ortho`, Shift's hold while drawing: a line's end is held level or plumb with its
|
||||
/// start, and each corner of connected lines or a polygon with the corner before.
|
||||
#[test]
|
||||
fn lines_and_corners_are_held_level_or_plumb_through_the_tool() {
|
||||
let dir = workdir("ortho");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for args in [
|
||||
json!({ "type": "line", "from": [20, 40], "to": [120, 52], "ortho": true }),
|
||||
json!({ "type": "arrow", "from": [180, 40], "to": [170, 140], "ortho": true }),
|
||||
json!({ "type": "polyline", "points": [[20, 200], [90, 210], [95, 260], [130, 250]], "ortho": true }),
|
||||
json!({ "type": "line", "from": [20, 280], "to": [120, 290] }),
|
||||
] {
|
||||
let mut args = args;
|
||||
args.as_object_mut().unwrap().extend([("doc".to_string(), json!(doc)), ("page".to_string(), json!(1))]);
|
||||
ok(&mut a, "comment_add", args);
|
||||
}
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 }));
|
||||
// In the order added: the list isn't sorted by index.
|
||||
let mut listed = list["comments"].as_array().unwrap().clone();
|
||||
listed.sort_by_key(|c| c["index"].as_u64());
|
||||
let points: Vec<Vec<[f64; 2]>> =
|
||||
listed.iter().map(|c| c["points"].as_array().unwrap().iter().map(|p| [p[0].as_f64().unwrap(), p[1].as_f64().unwrap()]).collect()).collect();
|
||||
let want: [&[[f64; 2]]; 4] = [
|
||||
&[[20.0, 40.0], [120.0, 40.0]],
|
||||
&[[180.0, 40.0], [180.0, 140.0]],
|
||||
&[[20.0, 200.0], [90.0, 200.0], [90.0, 260.0], [130.0, 260.0]],
|
||||
&[[20.0, 280.0], [120.0, 290.0]],
|
||||
];
|
||||
assert_eq!(points.len(), want.len(), "{points:?}");
|
||||
for (got, want) in points.iter().zip(want) {
|
||||
assert_eq!(got.len(), want.len(), "{got:?}");
|
||||
assert!(got.iter().zip(want).all(|(g, w)| (g[0] - w[0]).abs() < 0.01 && (g[1] - w[1]).abs() < 0.01), "{got:?} for {want:?}");
|
||||
}
|
||||
let refused = a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 50, 50], "ortho": true }));
|
||||
assert!(matches!(refused, Err(ToolError::InvalidArgs(_))), "ortho is for lines and corners: {refused:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
let dir = workdir("signing-graphic");
|
||||
|
||||
@@ -263,6 +263,15 @@ pub enum Edit {
|
||||
index: usize,
|
||||
fill: Option<Rgb>,
|
||||
},
|
||||
/// Draw a rectangle's, oval's, polygon's or text box's fill, or a text box's text, at an
|
||||
/// opacity of its own (0–1), the line or border keeping the comment's; `None` leaves that
|
||||
/// part as it is. `StyleAnnotation`'s opacity sets the whole comment's.
|
||||
PartOpacity {
|
||||
page: usize,
|
||||
index: usize,
|
||||
fill: Option<f64>,
|
||||
text: Option<f64>,
|
||||
},
|
||||
/// Change the font size (pt) of a text box, typewriter text or callout.
|
||||
FontSizeAnnotation {
|
||||
page: usize,
|
||||
|
||||
@@ -230,9 +230,11 @@ impl Edit {
|
||||
Edit::MoveAnnotation { .. } => Label::of("Move comment"),
|
||||
Edit::ReshapeAnnotation { .. } => Label::of("Reshape comment"),
|
||||
Edit::ResizeAnnotation { .. } => Label::of("Resize comment"),
|
||||
Edit::StyleAnnotation { .. } | Edit::FillAnnotation { .. } | Edit::FontSizeAnnotation { .. } | Edit::SetAnnotationInfo { .. } => {
|
||||
Label::of("Change comment properties")
|
||||
}
|
||||
Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::PartOpacity { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Label::of("Change comment properties"),
|
||||
Edit::SetFieldValue { name, .. } => Label::with("Fill in {name}", name.as_str()),
|
||||
Edit::SetFieldImage { name, .. } => Label::with("Set the image of {name}", name.as_str()),
|
||||
Edit::ResetForm { .. } => Label::of("Clear form"),
|
||||
|
||||
@@ -64,6 +64,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::PartOpacity { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. }
|
||||
| Edit::SetMeasurementScale { .. } => {
|
||||
@@ -575,6 +576,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
)?;
|
||||
}
|
||||
Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?,
|
||||
Edit::PartOpacity { page, index, fill, text } => pdfcraft_annot::set_part_opacity(doc, *page, *index, *fill, *text, &cx.meta())?,
|
||||
Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?,
|
||||
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
|
||||
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
|
||||
|
||||
@@ -68,6 +68,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::PartOpacity { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
|
||||
Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links,
|
||||
|
||||
@@ -2211,7 +2211,7 @@ fn rect_comment(page: usize, rect: [f64; 4]) -> Edit {
|
||||
Edit::AddAnnotation(NewAnnotation {
|
||||
page,
|
||||
shape: Shape::Rectangle { rect },
|
||||
style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) },
|
||||
style: Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]), ..Style::default() },
|
||||
contents: "Look here".into(),
|
||||
author: "Reviewer".into(),
|
||||
})
|
||||
|
||||
@@ -189,6 +189,12 @@ pub fn constrain(last: Point, point: Point) -> Point {
|
||||
let angle = (v[1].atan2(v[0]) / std::f64::consts::FRAC_PI_4).round() * std::f64::consts::FRAC_PI_4;
|
||||
[last[0] + length * angle.cos(), last[1] + length * angle.sin()]
|
||||
}
|
||||
/// Hold a proposed vertex level or plumb with the last vertex, whichever is nearer (a CAD
|
||||
/// drawing's ortho mode): the vertex slides onto that axis rather than turning onto it.
|
||||
pub fn ortho(last: Point, point: Point) -> Point {
|
||||
let v = sub(point, last);
|
||||
if v[0].abs() >= v[1].abs() { [point[0], last[1]] } else { [last[0], point[1]] }
|
||||
}
|
||||
struct Walker<'a> {
|
||||
doc: &'a Document,
|
||||
geometry: Geometry,
|
||||
|
||||
@@ -449,6 +449,16 @@ fn listing_skips_repeated_refs_and_caps_what_it_keeps() {
|
||||
assert!(!listing.measurements.is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn ortho_holds_a_vertex_level_or_plumb_with_the_last() {
|
||||
let last = [10.0, 20.0];
|
||||
assert_eq!(snap::ortho(last, [50.0, 27.0]), [50.0, 20.0], "mostly across: level");
|
||||
assert_eq!(snap::ortho(last, [-30.0, 13.0]), [-30.0, 20.0], "to the left too");
|
||||
assert_eq!(snap::ortho(last, [14.0, 80.0]), [10.0, 80.0], "mostly up: plumb");
|
||||
assert_eq!(snap::ortho(last, [8.0, -40.0]), [10.0, -40.0], "and down");
|
||||
assert_eq!(snap::ortho(last, [30.0, 40.0]), [30.0, 20.0], "a tie stays level");
|
||||
assert_eq!(snap::ortho(last, last), last);
|
||||
}
|
||||
#[test]
|
||||
fn a_stream_over_the_byte_budget_uses_it_up_once() {
|
||||
// 50 references to one Flate stream that inflates past the 16 MiB budget decode once.
|
||||
let big = pdfcraft_cos::Stream::flate(Dict::new(), &vec![b' '; 17 << 20]);
|
||||
|
||||
@@ -116,6 +116,8 @@ impl PdfCraftApp {
|
||||
"tool": t.command(),
|
||||
"color": s.color,
|
||||
"opacity": s.opacity,
|
||||
"fill_opacity": s.fill_opacity,
|
||||
"text_opacity": s.text_opacity,
|
||||
"width": s.width,
|
||||
"fill": s.fill,
|
||||
}))
|
||||
@@ -356,6 +358,8 @@ impl PdfCraftApp {
|
||||
if let Some(o) = e["opacity"].as_f64() {
|
||||
self.comment_prefs.set_opacity(tool, o);
|
||||
}
|
||||
// The fill's and text's own opacities, where saved (older settings have none).
|
||||
self.comment_prefs.set_part_opacity(tool, e["fill_opacity"].as_f64(), e["text_opacity"].as_f64());
|
||||
if let Some(w) = e["width"].as_f64() {
|
||||
self.comment_prefs.set_width(tool, w);
|
||||
}
|
||||
|
||||
@@ -492,6 +492,10 @@ pub struct DocView {
|
||||
pub forward: Vec<usize>,
|
||||
/// Pending navigation: page and fraction down the page to align with the viewport top.
|
||||
pub goto: Option<(usize, f32)>,
|
||||
/// With `goto`: → / ← turned the page at an edge, so a page or spread wider than the window
|
||||
/// starts at the edge the reader comes from (`false`: its left, after →; `true`: its right,
|
||||
/// after ←) instead of keeping the column.
|
||||
pub(crate) goto_side: Option<bool>,
|
||||
/// Pending position within that page from a destination ([`Self::go_to_dest`]): the point
|
||||
/// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points
|
||||
/// from the viewport's top-left corner.
|
||||
@@ -499,6 +503,12 @@ pub struct DocView {
|
||||
/// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down /
|
||||
/// Page Up where the pages scroll.
|
||||
pub key_scroll: f32,
|
||||
/// Pending keyboard scrolling sideways, in points right (negative: left): → / ← on a page
|
||||
/// wider than the window.
|
||||
pub key_side: f32,
|
||||
/// As last drawn: whether the view was at its left and at its right edge (both when the
|
||||
/// pages fit across), so → / ← turn the page there and scroll sideways elsewhere.
|
||||
side_edges: Option<(bool, bool)>,
|
||||
/// Single-page view, as last drawn: the page shown and whether the view was at its top and
|
||||
/// at its bottom, so ↑ / ↓ turn the page there instead of doing nothing (#273).
|
||||
single_edges: Option<(usize, bool, bool)>,
|
||||
@@ -928,8 +938,11 @@ impl DocView {
|
||||
back: Vec::new(),
|
||||
forward: Vec::new(),
|
||||
goto: None,
|
||||
goto_side: None,
|
||||
goto_point: None,
|
||||
key_scroll: 0.0,
|
||||
key_side: 0.0,
|
||||
side_edges: None,
|
||||
single_edges: None,
|
||||
flash: None,
|
||||
compare_marks: Vec::new(),
|
||||
@@ -1162,6 +1175,7 @@ struct PageEnv<'a> {
|
||||
info: &'a DocInfo,
|
||||
initials: &'a Option<crate::fill_sign::SavedSig>,
|
||||
key_scroll: f32,
|
||||
key_side: f32,
|
||||
middle_gesture: bool,
|
||||
modal: bool,
|
||||
navigation_enabled: bool,
|
||||
@@ -1211,6 +1225,9 @@ struct PageOut {
|
||||
page_action: Option<PageMenuAction>,
|
||||
primary_down: bool,
|
||||
refused: Option<String>,
|
||||
/// The current page or spread where it is drawn, every page of it: one off screen (the second
|
||||
/// page of a zoomed spread) has no screen transform, but still decides where the row ends.
|
||||
row_on_screen: Option<Rect>,
|
||||
stamp_placed: bool,
|
||||
}
|
||||
|
||||
@@ -1597,6 +1614,7 @@ fn draw_pages(
|
||||
hand,
|
||||
info,
|
||||
key_scroll,
|
||||
key_side,
|
||||
middle_gesture,
|
||||
modal,
|
||||
navigation_enabled,
|
||||
@@ -1626,7 +1644,7 @@ fn draw_pages(
|
||||
// ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution
|
||||
// until after the text widgets when a selection owns that drag.
|
||||
let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + coarse_scroll };
|
||||
let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide;
|
||||
let delta = auto_delta + edge_delta - vec2(key_side, key_scroll) + wheel_delta + glide;
|
||||
if delta != Vec2::ZERO && delta.is_finite() {
|
||||
ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none());
|
||||
}
|
||||
@@ -1779,6 +1797,7 @@ fn draw_pages(
|
||||
page_action: _,
|
||||
primary_down: _,
|
||||
refused,
|
||||
row_on_screen: _,
|
||||
stamp_placed,
|
||||
} = page_out;
|
||||
let mut page_action: Option<PageMenuAction> = None;
|
||||
@@ -1831,6 +1850,9 @@ fn draw_pages(
|
||||
if view.continuous {
|
||||
view.current = current;
|
||||
}
|
||||
// The row as it is now: in continuous view the current page may have changed above.
|
||||
let row_on_screen =
|
||||
shown_row(view, env.rects).iter().filter_map(|&i| env.rects.get(i)).map(|r| r.translate(origin.to_vec2())).reduce(Rect::union);
|
||||
resp.context_menu(|ui| {
|
||||
let is_crop_over_image = tool == QuickTool::EditText
|
||||
&& view.image_selection.clone().is_some_and(|sel| {
|
||||
@@ -2006,6 +2028,7 @@ fn draw_pages(
|
||||
page_action,
|
||||
primary_down: ptr.primary_down,
|
||||
refused,
|
||||
row_on_screen,
|
||||
stamp_placed,
|
||||
};
|
||||
(wanted, visible_now)
|
||||
@@ -2468,6 +2491,7 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry
|
||||
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
|
||||
scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size()));
|
||||
} else if let Some((page, frac)) = view.goto.take() {
|
||||
let side = view.goto_side.take();
|
||||
let page = page.min(rects.len() - 1);
|
||||
let r = rects[page];
|
||||
// Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap()
|
||||
@@ -2483,7 +2507,27 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry
|
||||
// quick-action bar as the layout does.
|
||||
let (x, w) = (view.last_scroll.x, avail.width());
|
||||
let (left, right) = (r.left() - SIDE, r.right() + SIDE);
|
||||
let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) };
|
||||
// Turned by → / ← at an edge (`goto_side`): a page or spread wider than the window starts at
|
||||
// the edge the reader comes from.
|
||||
let row = shown_row(view, rects).iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union).unwrap_or(r);
|
||||
let (row_left, row_right) = (row.left() - SIDE, row.right() + SIDE);
|
||||
let target = match side {
|
||||
Some(from_right) if row_right - row_left > w => {
|
||||
if from_right {
|
||||
row_right - w
|
||||
} else {
|
||||
row_left
|
||||
}
|
||||
}
|
||||
_ if right - left <= w => {
|
||||
if left >= x && right <= x + w {
|
||||
x
|
||||
} else {
|
||||
r.center().x - w / 2.0
|
||||
}
|
||||
}
|
||||
_ => x.clamp(left, right - w),
|
||||
};
|
||||
if (target - x).abs() > 0.5 {
|
||||
scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0)));
|
||||
}
|
||||
@@ -2733,6 +2777,7 @@ struct GlideCx {
|
||||
now: f64,
|
||||
may_glide: bool,
|
||||
key_scroll: f32,
|
||||
key_side: f32,
|
||||
auto_delta: Vec2,
|
||||
zoom_delta: f32,
|
||||
edge_delta: Vec2,
|
||||
@@ -2746,11 +2791,11 @@ struct GlideCx {
|
||||
/// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for
|
||||
/// a reader action. Returns this frame's glide.
|
||||
fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 {
|
||||
let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx;
|
||||
let GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx;
|
||||
let over_page = ui.rect_contains_pointer(avail);
|
||||
ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page));
|
||||
let interrupted = glide_interrupted(ui);
|
||||
if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 {
|
||||
if !may_glide || interrupted || key_scroll != 0.0 || key_side != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 {
|
||||
view.fling.stop();
|
||||
}
|
||||
let glide = view.fling.step(now);
|
||||
@@ -2832,7 +2877,8 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
};
|
||||
// Keyboard scrolling applies to this frame's page view only, never later.
|
||||
let key_scroll = std::mem::take(&mut app.views[index].key_scroll);
|
||||
if key_scroll != 0.0 {
|
||||
let key_side = std::mem::take(&mut app.views[index].key_side);
|
||||
if key_scroll != 0.0 || key_side != 0.0 {
|
||||
app.views[index].end_find_following();
|
||||
}
|
||||
// The Search panel closed: its search moves to the find bar.
|
||||
@@ -2932,7 +2978,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
read_wheel(ui, view, avail, single_paging, edge_turned, wheel_enabled, animates);
|
||||
let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates;
|
||||
let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || coarse_scroll != Vec2::ZERO || has_page;
|
||||
let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled };
|
||||
let motion = GlideCx { now, may_glide, key_scroll, key_side, auto_delta, zoom_delta, edge_delta, wheel_scrolled };
|
||||
let glide = step_glide(ui, view, avail, &motion);
|
||||
// The wheel may have turned the page above: draw the row it turned to. Measured from the old
|
||||
// row, the jump to the new page scrolled both out of view, a blank frame on every turn forward.
|
||||
@@ -3039,6 +3085,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
info,
|
||||
initials: &initials,
|
||||
key_scroll,
|
||||
key_side,
|
||||
middle_gesture,
|
||||
modal,
|
||||
navigation_enabled,
|
||||
@@ -3089,6 +3136,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
page_action,
|
||||
primary_down,
|
||||
mut refused,
|
||||
row_on_screen,
|
||||
stamp_placed,
|
||||
} = page_out;
|
||||
view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| {
|
||||
@@ -3098,6 +3146,15 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0);
|
||||
(page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0)
|
||||
});
|
||||
// Within a point, as above: at an edge of the scrolled content, or of the current page or
|
||||
// spread shown whole that way (in continuous view the content is as wide as the widest page,
|
||||
// and a narrower current page shows whole inside it).
|
||||
let max_x = (out.content_size.x - out.inner_rect.width()).max(0.0);
|
||||
let inner = out.inner_rect;
|
||||
view.side_edges = Some((
|
||||
out.state.clamped_offset().x <= 1.0 || row_on_screen.is_some_and(|r| r.left() >= inner.left() - 1.0),
|
||||
out.state.clamped_offset().x >= max_x - 1.0 || row_on_screen.is_some_and(|r| r.right() <= inner.right() + 1.0),
|
||||
));
|
||||
|
||||
view.viewport_screen = out.inner_rect;
|
||||
view.auto_scroll.paint(ui, avail);
|
||||
|
||||
@@ -424,14 +424,39 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
|
||||
}
|
||||
}
|
||||
// As in Acrobat: → / ← go to the next / previous page in every layout (#185), and so do
|
||||
// ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view.
|
||||
// ⌘Page Down / ⌘Page Up, or plain Page Down / Page Up in single-page view. On a page wider
|
||||
// than the window (a plan zoomed in) → / ← scroll sideways a line first, and turn the page
|
||||
// once there is nothing left to scroll that way, as ↓ / ↑ do in single-page view; ⌘→ / ⌘←
|
||||
// turn it at once.
|
||||
let command = ctx.input(|i| i.modifiers.command);
|
||||
let scrolls = view.continuous;
|
||||
if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) {
|
||||
let line = KEY_SCROLL_LINE * speed.factor();
|
||||
// Only an up-to-date view counts: no jump pending.
|
||||
let sideways = |view: &DocView, right: bool| {
|
||||
!command && view.goto.is_none() && view.side_edges.is_some_and(|(left_edge, right_edge)| !if right { right_edge } else { left_edge })
|
||||
};
|
||||
// Turned at an edge, the next page starts at its left and the previous one at its right, as
|
||||
// reading on across: on two wide pages → doesn't turn twice without showing the next one's left.
|
||||
let at_edge = |view: &mut DocView, right: bool| {
|
||||
if !command && view.goto.is_some() {
|
||||
view.goto_side = Some(!right);
|
||||
}
|
||||
};
|
||||
if key(Key::ArrowRight) && sideways(view, true) {
|
||||
view.key_side += line;
|
||||
} else if key(Key::ArrowRight) || (key(Key::PageDown) && (command || !scrolls)) {
|
||||
view.step_page(true);
|
||||
if key(Key::ArrowRight) {
|
||||
at_edge(view, true);
|
||||
}
|
||||
}
|
||||
if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) {
|
||||
if key(Key::ArrowLeft) && sideways(view, false) {
|
||||
view.key_side -= line;
|
||||
} else if key(Key::ArrowLeft) || (key(Key::PageUp) && (command || !scrolls)) {
|
||||
view.step_page(false);
|
||||
if key(Key::ArrowLeft) {
|
||||
at_edge(view, false);
|
||||
}
|
||||
}
|
||||
// ↓ / ↑ scroll a line, and Page Down / Page Up a screen where the pages scroll. In
|
||||
// single-page view, ↓ / ↑ turn the page once there is nothing left to scroll that way, as
|
||||
@@ -439,7 +464,6 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s
|
||||
// The Scroll speed preference sets how far ↓ / ↑ go as well as the wheel (#985); Page Down /
|
||||
// Page Up keep a screen.
|
||||
if !command {
|
||||
let line = KEY_SCROLL_LINE * speed.factor();
|
||||
let screen = (view.viewport_h - KEY_SCROLL_LINE).max(KEY_SCROLL_LINE);
|
||||
let steps = [(Key::ArrowDown, line), (Key::ArrowUp, -line), (Key::PageDown, screen), (Key::PageUp, -screen)];
|
||||
for (k, by) in steps {
|
||||
|
||||
@@ -108,8 +108,13 @@ impl PdfCraftApp {
|
||||
style.fill = p.fill;
|
||||
}
|
||||
self.comment_prefs.set_style(tool, style);
|
||||
// The fill's and text's own opacities where they show (else the opacity is the whole
|
||||
// comment's). With neither a border nor a fill the text is the whole comment: its drawn
|
||||
// opacity, which Comment Properties shows, is the whole comment's default.
|
||||
let (fill, text) = parts_shown(p);
|
||||
// Keep invisible defaults out of tools where a zero stroke would make them disappear.
|
||||
self.comment_prefs.set_opacity(tool, p.opacity);
|
||||
self.comment_prefs.set_opacity(tool, p.text_opacity.filter(|_| !text).unwrap_or(p.opacity));
|
||||
self.comment_prefs.set_part_opacity(tool, p.fill_opacity.filter(|_| fill), p.text_opacity.filter(|_| text));
|
||||
if let Some(w) = p.width {
|
||||
self.comment_prefs.set_width(tool, w);
|
||||
}
|
||||
@@ -125,6 +130,20 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the comment's parts show an opacity of their own: (its fill, its text). A text box's
|
||||
/// text, border and fill each do once it has a border or a fill; a filled shape's line and fill
|
||||
/// do; anything else has one opacity for the whole comment.
|
||||
pub fn parts_shown(p: &CommentProps) -> (bool, bool) {
|
||||
let fill = p.fill_opacity.is_some() && p.fill.is_some();
|
||||
let text = p.text_opacity.is_some() && (fill || has_border(p));
|
||||
(fill, text)
|
||||
}
|
||||
|
||||
/// A text box's, typewriter text's or callout's border is drawn.
|
||||
fn has_border(p: &CommentProps) -> bool {
|
||||
p.font_size.is_some() && p.width.is_some_and(|w| w > 0.0)
|
||||
}
|
||||
|
||||
/// The edits that make the comment match the draft (empty when nothing changed).
|
||||
pub fn edits(d: &PropsDraft) -> Vec<Edit> {
|
||||
let (o, e) = (&d.original, &d.edited);
|
||||
@@ -143,6 +162,15 @@ pub fn edits(d: &PropsDraft) -> Vec<Edit> {
|
||||
if e.restylable && e.fillable && e.fill != o.fill {
|
||||
out.push(Edit::FillAnnotation { page: d.page, index: d.index, fill: e.fill });
|
||||
}
|
||||
// After the opacity, which is the whole comment's: the parts shown are set again.
|
||||
let (fill, text) = parts_shown(e);
|
||||
let moved = |a: Option<f64>, b: Option<f64>| a.zip(b).is_some_and(|(a, b)| (a - b).abs() > 1e-6);
|
||||
if e.restylable
|
||||
&& (fill || text)
|
||||
&& (opacity.is_some() || fill && moved(e.fill_opacity, o.fill_opacity) || text && moved(e.text_opacity, o.text_opacity))
|
||||
{
|
||||
out.push(Edit::PartOpacity { page: d.page, index: d.index, fill: e.fill_opacity.filter(|_| fill), text: e.text_opacity.filter(|_| text) });
|
||||
}
|
||||
if e.restylable
|
||||
&& let Some(size) = e.font_size.filter(|s| Some(*s) != o.font_size)
|
||||
{
|
||||
@@ -231,12 +259,51 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
ui.add(egui::DragValue::new(size).range(1.0..=400.0).speed(0.5).suffix(" pt"));
|
||||
ui.end_row();
|
||||
}
|
||||
ui.label(tl!("Opacity"));
|
||||
let mut pct = e.opacity * 100.0;
|
||||
if ui.add(egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).changed() {
|
||||
e.opacity = pct / 100.0;
|
||||
// One opacity for the whole comment, or each part's of its own: a text box's
|
||||
// text, border and fill, a shape's line and fill. The rows stay put; a part
|
||||
// not drawn (no fill, no border) is greyed.
|
||||
let percent = |ui: &mut egui::Ui, label: &str, value: &mut f64, enabled: bool| {
|
||||
let name = ui.add_enabled(enabled, egui::Label::new(label));
|
||||
let mut pct = *value * 100.0;
|
||||
if ui.add_enabled(enabled, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%")).labelled_by(name.id).changed() {
|
||||
*value = pct / 100.0;
|
||||
}
|
||||
ui.end_row();
|
||||
};
|
||||
let (fill, text) = parts_shown(e);
|
||||
let border = has_border(e);
|
||||
match (e.text_opacity.is_some(), e.fill_opacity.is_some()) {
|
||||
(true, _) => {
|
||||
// With neither a border nor a fill, the text is the whole comment:
|
||||
// shown at the opacity it is drawn at, a change sets the whole
|
||||
// comment's (which its parts then take).
|
||||
match e.text_opacity.as_mut() {
|
||||
Some(t) if text => percent(ui, tl!("Text opacity"), t, true),
|
||||
drawn => {
|
||||
let mut shown = drawn.as_deref().copied().unwrap_or(e.opacity);
|
||||
let before = shown;
|
||||
percent(ui, tl!("Text opacity"), &mut shown, true);
|
||||
if shown != before {
|
||||
e.opacity = shown;
|
||||
if let Some(t) = drawn {
|
||||
*t = shown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if text {
|
||||
percent(ui, tl!("Border opacity"), &mut e.opacity, border);
|
||||
} else {
|
||||
let mut whole = e.opacity;
|
||||
percent(ui, tl!("Border opacity"), &mut whole, false);
|
||||
}
|
||||
}
|
||||
(false, true) => percent(ui, tl!("Line opacity"), &mut e.opacity, true),
|
||||
(false, false) => percent(ui, tl!("Opacity"), &mut e.opacity, true),
|
||||
}
|
||||
if let Some(f) = e.fill_opacity.as_mut() {
|
||||
percent(ui, tl!("Fill opacity"), f, fill);
|
||||
}
|
||||
ui.end_row();
|
||||
let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 };
|
||||
if let Some(w) = e.width.as_mut() {
|
||||
ui.label(tl!("Thickness"));
|
||||
|
||||
@@ -904,6 +904,7 @@ fn comment_page(edit: &Edit) -> Option<usize> {
|
||||
| Edit::ResizeAnnotation { page, .. }
|
||||
| Edit::StyleAnnotation { page, .. }
|
||||
| Edit::FillAnnotation { page, .. }
|
||||
| Edit::PartOpacity { page, .. }
|
||||
| Edit::FontSizeAnnotation { page, .. }
|
||||
| Edit::SetAnnotationInfo { page, .. } => Some(*page),
|
||||
_ => None,
|
||||
|
||||
@@ -353,6 +353,15 @@ impl CommentPrefs {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool's fill's own opacity (0–100 %) and a text box's or callout's text's (10–100 %),
|
||||
/// for new comments; `None`: as the tool's opacity.
|
||||
pub fn set_part_opacity(&mut self, tool: CommentTool, fill: Option<f64>, text: Option<f64>) {
|
||||
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
|
||||
s.fill_opacity = fill.filter(|o| o.is_finite() && tool.has_fill()).map(|o| o.clamp(0.0, 1.0));
|
||||
s.text_opacity = text.filter(|o| o.is_finite() && matches!(tool, CommentTool::TextBox | CommentTool::Callout)).map(|o| o.clamp(0.1, 1.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// The tool's fill (`None`: no fill).
|
||||
pub fn set_fill(&mut self, tool: CommentTool, fill: Option<Rgb>) {
|
||||
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
|
||||
|
||||
@@ -32,15 +32,21 @@ pub(super) fn point_handle(cx: &PageCx<'_>, a: &Annotation, p: Pos2) -> Option<u
|
||||
a.points.iter().position(|q| cx.to_screen([f64::from(q[0]), f64::from(q[1])]).distance(p) <= 7.0)
|
||||
}
|
||||
|
||||
/// `points` with point `i` moved to `to`; with Shift, held to the nearest 45° from the point
|
||||
/// before it (a line's other end; the first corner turns about the second).
|
||||
/// `to` held level or plumb with `last` while Shift is down (ortho, as in a CAD drawing): the
|
||||
/// next end or corner of a line, arrow, connected lines, polygon or cloud.
|
||||
pub(crate) fn held(last: Option<&[f64; 2]>, to: [f64; 2], shift: bool) -> [f64; 2] {
|
||||
match last {
|
||||
Some(l) if shift => pdfcraft_engine::measure::snap::ortho(*l, to),
|
||||
_ => to,
|
||||
}
|
||||
}
|
||||
|
||||
/// `points` with point `i` moved to `to`; with Shift, held level or plumb with the point before
|
||||
/// it (a line's other end; the first corner is held to the second).
|
||||
pub(crate) fn reshaped(points: &[[f64; 2]], i: usize, to: [f64; 2], shift: bool) -> Vec<[f64; 2]> {
|
||||
let mut out = points.to_vec();
|
||||
let anchor = if i == 0 { points.get(1) } else { points.get(i - 1) };
|
||||
let to = match anchor {
|
||||
Some(a) if shift => pdfcraft_engine::measure::snap::constrain(*a, to),
|
||||
_ => to,
|
||||
};
|
||||
let to = held(anchor, to, shift);
|
||||
if let Some(p) = out.get_mut(i) {
|
||||
*p = to;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::geometry::{
|
||||
CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, is_markup, point_handle, reaches_points, reshapable, reshaped, resizable,
|
||||
resized, snapped_caret,
|
||||
CARET_SIZE, HANDLES, clamp_to, corner_ratio, drawn_box, handle_pos, held, is_markup, point_handle, reaches_points, reshapable, reshaped,
|
||||
resizable, resized, snapped_caret,
|
||||
};
|
||||
use super::paint::{drawn_shape, edit_anchor, erase, new_comment};
|
||||
use super::paint::{closes_at, drawn_shape, edit_anchor, erase, new_comment};
|
||||
use super::state::{Composer, ComposerKind, Gesture, PageCx};
|
||||
use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
|
||||
use crate::page_cursor::{Layer, Subject};
|
||||
@@ -346,6 +346,8 @@ impl Gestures<'_> {
|
||||
}
|
||||
|
||||
fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool {
|
||||
// Shift holds a line level or plumb (ortho).
|
||||
let shift = self.resp.ctx.input(|i| i.modifiers.shift);
|
||||
let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self;
|
||||
let cv = &mut view.comments;
|
||||
if !cx.allowed {
|
||||
@@ -374,7 +376,8 @@ impl Gestures<'_> {
|
||||
}
|
||||
} else {
|
||||
points.truncate(1);
|
||||
points.push(u);
|
||||
let line = matches!(*tool, CommentTool::Line | CommentTool::Arrow);
|
||||
points.push(held(points.first(), u, line && shift));
|
||||
}
|
||||
}
|
||||
if resp.drag_stopped()
|
||||
@@ -408,6 +411,8 @@ impl Gestures<'_> {
|
||||
}
|
||||
|
||||
fn click_points(&self, view: &mut DocView, tool: CommentTool) -> bool {
|
||||
// Shift holds the next side of connected lines, a polygon or a cloud level or plumb.
|
||||
let shift = self.resp.ctx.input(|i| i.modifiers.shift);
|
||||
let Self { ui, resp, cx, pointer, over_page, .. } = *self;
|
||||
let cv = &mut view.comments;
|
||||
if !cx.allowed {
|
||||
@@ -434,8 +439,9 @@ impl Gestures<'_> {
|
||||
match cv.gesture.as_mut() {
|
||||
Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => {
|
||||
// Clicking the first point again closes a polygon.
|
||||
let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0;
|
||||
let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0);
|
||||
let closes = closes_at(cx, tool, points, p);
|
||||
let u = held(points.last(), u, shift);
|
||||
let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(cx.to_screen(u)) < 2.0);
|
||||
if closes {
|
||||
let pts = std::mem::take(points);
|
||||
cv.gesture = None;
|
||||
|
||||
@@ -327,8 +327,10 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m
|
||||
ui.set_min_width(180.0);
|
||||
let mut percent = (style.opacity * 100.0).round();
|
||||
ui.label(egui::RichText::new(tl!("Opacity")).font(theme::semibold(12.0)));
|
||||
// The whole comment's: its fill and text take it too.
|
||||
if ui.add(egui::Slider::new(&mut percent, 10.0..=100.0).step_by(5.0).suffix(" %")).changed() {
|
||||
prefs.set_opacity(tool, percent / 100.0);
|
||||
prefs.set_part_opacity(tool, None, None);
|
||||
}
|
||||
});
|
||||
if tool.has_width() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::geometry::{HANDLES, handle_pos, is_markup, reaches_points, reshapable, reshaped, resizable};
|
||||
use super::geometry::{HANDLES, handle_pos, held, is_markup, reaches_points, reshapable, reshaped, resizable};
|
||||
use super::state::{CommentView, Gesture, PageCx, SETTLE_SECS};
|
||||
use super::{
|
||||
Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape,
|
||||
@@ -165,10 +165,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin
|
||||
painter.add(egui::Shape::ellipse_stroke(r.center(), r.size() / 2.0, stroke));
|
||||
}
|
||||
CommentTool::Polygon | CommentTool::PolyLine | CommentTool::Cloud => {
|
||||
// The vertices so far, and a rubber band to the pointer.
|
||||
// The vertices so far, and a rubber band to where a click would put the next one: the
|
||||
// first, where the click closes the shape, Shift or not.
|
||||
let mut line = pts.clone();
|
||||
if let Some(p) = cx.ptr.pos {
|
||||
line.push(p);
|
||||
let shift = painter.ctx().input(|i| i.modifiers.shift);
|
||||
line.push(if closes_at(cx, *tool, points, p) { pts[0] } else { cx.to_screen(held(points.last(), cx.to_user(p), shift)) });
|
||||
}
|
||||
painter.add(egui::Shape::line(line, stroke));
|
||||
if let Some(first) = pts.first().filter(|_| *tool != CommentTool::PolyLine && pts.len() >= 3) {
|
||||
@@ -187,6 +189,12 @@ fn paint_draw(painter: &egui::Painter, cx: &PageCx<'_>, tool: &CommentTool, poin
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a click at `p` (on screen) closes the polygon or cloud drawn so far (`points`, user
|
||||
/// space): on its first point again, once it has three.
|
||||
pub(super) fn closes_at(cx: &PageCx<'_>, tool: CommentTool, points: &[[f64; 2]], p: Pos2) -> bool {
|
||||
points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0
|
||||
}
|
||||
|
||||
/// The shape a finished drawing gesture makes (`None` if it is too small to mean anything).
|
||||
pub(super) fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option<Shape> {
|
||||
let (first, last) = (*points.first()?, *points.last()?);
|
||||
|
||||
@@ -956,8 +956,8 @@ fn comments_are_selected_moved_and_deleted_with_the_select_tool() {
|
||||
}
|
||||
|
||||
/// A line's ends have handles: dragging one with the Select tool changes the line's length and
|
||||
/// angle (Shift holds it to 45°), as one undo step. The line tool keeps drawing from an end: a
|
||||
/// new line starts there rather than dragging the one just drawn.
|
||||
/// angle (Shift holds it level or plumb with the other end), as one undo step. The line tool keeps
|
||||
/// drawing from an end: a new line starts there rather than dragging the one just drawn.
|
||||
#[test]
|
||||
fn line_ends_are_dragged_to_change_length_and_angle() {
|
||||
let mut h = harness(|app| app.set_option("quick", "line").unwrap());
|
||||
@@ -981,7 +981,7 @@ fn line_ends_are_dragged_to_change_length_and_angle() {
|
||||
assert!((p[1][0] - 200.0).abs() < 1.0 && (p[1][1] - 160.0).abs() < 1.0, "the dragged end follows: {p:?}");
|
||||
let id = h.state().views[0].id;
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Reshape comment"));
|
||||
// Shift: held to the nearest 45° from the other end (39° here).
|
||||
// Shift: held level or plumb with the other end, here level (110 pt across, 90 up).
|
||||
let a = at(&h, 200.0, 160.0);
|
||||
let b = at(&h, 150.0, 190.0);
|
||||
h.hover_at(a);
|
||||
@@ -998,8 +998,7 @@ fn line_ends_are_dragged_to_change_length_and_angle() {
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
let p = ends(&h);
|
||||
let (dx, dy) = (p[1][0] - p[0][0], p[1][1] - p[0][1]);
|
||||
assert!(dx > 50.0 && (dx - dy).abs() < 0.5, "45°: {p:?}");
|
||||
assert!((p[1][1] - p[0][1]).abs() < 0.01 && (p[1][0] - 150.0).abs() < 1.0, "level: {p:?}");
|
||||
// Undo puts the line back as it was before the drag.
|
||||
h.state_mut().undo();
|
||||
h.run_steps(2);
|
||||
@@ -1083,6 +1082,80 @@ fn alt_arrows_navigate_instead_of_nudging_the_selected_comment() {
|
||||
assert!(comments(&h)[0].rect[0] > before[0], "→ nudged it");
|
||||
}
|
||||
|
||||
/// Over the first corner, where a click closes the polygon, the rubber band runs to that corner
|
||||
/// with Shift held too: the closing side the click will make, not a level or plumb one.
|
||||
#[test]
|
||||
fn the_rubber_band_shows_the_closing_side_with_shift_held() {
|
||||
let _gpu = crate::gpu();
|
||||
let mut h = harness(|app| app.set_option("quick", "polygon").unwrap());
|
||||
for p in [(40.0, 40.0), (140.0, 40.0), (140.0, 140.0)] {
|
||||
click_pt(&mut h, p);
|
||||
}
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));
|
||||
h.hover_at(at(&h, 40.0, 40.0) + egui::vec2(2.0, 2.0));
|
||||
h.run_steps(3);
|
||||
let pixels = h.render().unwrap();
|
||||
// Halfway along the diagonal closing side, (90, 90).
|
||||
let m = at(&h, 90.0, 90.0);
|
||||
let drawn = (-2..=2).any(|d: i32| {
|
||||
let v = pixels.get_pixel((m.x as i32 + d) as u32, (m.y as i32 + d) as u32).0;
|
||||
v[0] > 150 && v[1] < 100 && v[2] < 100
|
||||
});
|
||||
assert!(drawn, "the closing side is drawn");
|
||||
h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
/// Shift holds a new line or arrow level or plumb with where it starts, and each new side of
|
||||
/// connected lines or a polygon with the corner before (a CAD drawing's ortho mode). Without
|
||||
/// Shift they go where the pointer is.
|
||||
#[test]
|
||||
fn shift_holds_new_lines_and_sides_level_or_plumb() {
|
||||
let mut h = harness(|app| app.set_option("quick", "line").unwrap());
|
||||
let shift = |h: &mut Harness<'static, PdfCraftApp>, on: bool| {
|
||||
h.event(egui::Event::ModifiersChanged(if on { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }));
|
||||
h.run_steps(1);
|
||||
};
|
||||
let points = |h: &Harness<'static, PdfCraftApp>, subtype: &str| {
|
||||
// In the order drawn: the list isn't sorted by index.
|
||||
let mut drawn: Vec<_> = comments(h).into_iter().filter(|a| a.subtype == subtype).collect();
|
||||
drawn.sort_by_key(|a| a.index);
|
||||
drawn.into_iter().map(|a| a.points).collect::<Vec<_>>()
|
||||
};
|
||||
let close = |p: [f32; 2], q: [f32; 2]| (p[0] - q[0]).abs() < 1.0 && (p[1] - q[1]).abs() < 1.0;
|
||||
shift(&mut h, true);
|
||||
drag_pt(&mut h, (40.0, 100.0), (140.0, 120.0));
|
||||
drag_pt(&mut h, (200.0, 40.0), (215.0, 150.0));
|
||||
shift(&mut h, false);
|
||||
drag_pt(&mut h, (40.0, 160.0), (140.0, 190.0));
|
||||
let lines = points(&h, "Line");
|
||||
assert_eq!(lines.len(), 3, "{lines:?}");
|
||||
assert!(close(lines[0][0], [40.0, 100.0]) && close(lines[0][1], [140.0, 100.0]), "mostly across: level: {lines:?}");
|
||||
assert!(close(lines[1][0], [200.0, 40.0]) && close(lines[1][1], [200.0, 150.0]), "mostly up: plumb: {lines:?}");
|
||||
assert!(close(lines[2][1], [140.0, 190.0]), "no Shift, no hold: {lines:?}");
|
||||
|
||||
h.state_mut().set_option("quick", "arrow").unwrap();
|
||||
shift(&mut h, true);
|
||||
drag_pt(&mut h, (260.0, 180.0), (150.0, 170.0));
|
||||
shift(&mut h, false);
|
||||
let arrow = points(&h, "Line").pop().expect("an arrow");
|
||||
assert!(close(arrow[1], [150.0, 180.0]), "an arrow too, leftwards: {arrow:?}");
|
||||
|
||||
// Connected lines: each click with Shift is held to the corner before.
|
||||
h.state_mut().set_option("quick", "polyline").unwrap();
|
||||
click_pt(&mut h, (20.0, 20.0));
|
||||
shift(&mut h, true);
|
||||
click_pt(&mut h, (90.0, 30.0));
|
||||
click_pt(&mut h, (95.0, 80.0));
|
||||
shift(&mut h, false);
|
||||
click_pt(&mut h, (130.0, 60.0));
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let poly = points(&h, "PolyLine").pop().expect("connected lines");
|
||||
assert_eq!(poly.len(), 4, "{poly:?}");
|
||||
assert!(close(poly[1], [90.0, 20.0]) && close(poly[2], [90.0, 80.0]) && close(poly[3], [130.0, 60.0]), "{poly:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shape_just_drawn_resizes_from_its_handles_without_the_select_tool() {
|
||||
// #2338: the new shape showed its handles, but only the Select tool answered them.
|
||||
@@ -1287,6 +1360,91 @@ fn shapes_take_a_fill_colour_from_the_quick_bar_and_properties() {
|
||||
assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill, Some([0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
/// A filled rectangle's line and fill, and a text box's text, border and fill, each take an
|
||||
/// opacity of their own in Properties; Make Properties Default carries them into the tool, so
|
||||
/// the next rectangle is drawn with a see-through fill and an opaque line.
|
||||
#[test]
|
||||
fn comment_properties_set_each_parts_opacity() {
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
h.state_mut().comment_prefs.set_fill(CommentTool::Rectangle, Some([1.0, 1.0, 0.0]));
|
||||
h.run_steps(2);
|
||||
drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0));
|
||||
let props = |h: &Harness<'static, PdfCraftApp>, index: usize| {
|
||||
let s = h.state();
|
||||
let p = s.session.get(s.views[0].id).unwrap().comment_props(0, index).unwrap();
|
||||
(p.opacity, p.fill_opacity, p.text_opacity)
|
||||
};
|
||||
h.state_mut().open_comment_props(0, 0);
|
||||
h.run_steps(2);
|
||||
// The sliders (each labelled by its row, which shares the name).
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Slider, "Line opacity");
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Slider, "Fill opacity");
|
||||
assert_eq!(h.query_all_by_label("Text opacity").count(), 0, "a rectangle has no text");
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
d.edited.fill_opacity = Some(0.3);
|
||||
d.make_default = true;
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(props(&h, 0), (1.0, Some(0.3), None));
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Change comment properties"));
|
||||
assert_eq!(h.state().comment_prefs.style(CommentTool::Rectangle).fill_opacity, Some(0.3));
|
||||
h.state_mut().set_option("quick", "square").unwrap();
|
||||
h.run_steps(2);
|
||||
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));
|
||||
assert_eq!(props(&h, 1), (1.0, Some(0.3), None), "the next rectangle takes the default");
|
||||
|
||||
// A text box with a border and a fill: its text, border and fill.
|
||||
let shape = pdfcraft_engine::Shape::TextBox { rect: [40.0, 120.0, 200.0, 160.0], font_size: 12.0 };
|
||||
let mut style = pdfcraft_engine::Style::default_for(&shape);
|
||||
(style.width, style.fill) = (1.0, Some([1.0, 1.0, 0.6]));
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "Note".into(),
|
||||
author: "Tester".into(),
|
||||
})));
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_comment_props(0, 2);
|
||||
h.run_steps(2);
|
||||
for label in ["Text opacity", "Border opacity", "Fill opacity"] {
|
||||
h.get_by_role_and_label(egui::accesskit::Role::Slider, label);
|
||||
}
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||||
let _gpu = crate::gpu();
|
||||
h.render().unwrap().save(format!("{dir}/comment-part-opacity.png")).unwrap();
|
||||
}
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
(d.edited.opacity, d.edited.fill_opacity) = (0.5, Some(0.2));
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(props(&h, 2), (0.5, Some(0.2), Some(1.0)), "the border at half, the fill at a fifth, the text opaque");
|
||||
// Without its fill and border, the text is the whole text box: its opacity is the comment's.
|
||||
h.state_mut().open_comment_props(0, 2);
|
||||
h.run_steps(2);
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
(d.edited.fill, d.edited.width) = (None, Some(0.0));
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
h.state_mut().open_comment_props(0, 2);
|
||||
h.run_steps(2);
|
||||
let shown = egui_kittest::kittest::NodeT::accesskit_node(&h.get_by_role_and_label(egui::accesskit::Role::Slider, "Text opacity")).numeric_value();
|
||||
assert_eq!(shown, Some(100.0), "the text's slider shows the opacity it is drawn at, not the border's half");
|
||||
h.state_mut().comment_props.as_mut().expect("open").edited.opacity = 0.4;
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(props(&h, 2), (0.4, Some(0.4), Some(0.4)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_filters_and_sorts() {
|
||||
let mut h = harness(|app| {
|
||||
@@ -2174,6 +2332,43 @@ fn properties_dialog_makes_the_strikethrough_colour_default() {
|
||||
assert!(made.iter().zip(green).all(|(m, r)| (f64::from(*m) - r).abs() < 0.01), "the next strikethrough is green: {made:?}");
|
||||
}
|
||||
|
||||
/// A text box with neither a border nor a fill is all text: Make Properties Default (and the
|
||||
/// context menu's Make Current Properties Default) gives the tool the opacity its text is drawn
|
||||
/// at, the one Comment Properties shows, not the comment's /CA.
|
||||
#[test]
|
||||
fn a_text_only_text_box_made_default_gives_the_tool_its_texts_opacity() {
|
||||
use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style};
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
let mut h = harness(|_| {});
|
||||
let shape = Shape::TextBox { rect: [100.0, 600.0, 220.0, 630.0], font_size: 12.0 };
|
||||
let style = Style { width: 0.0, fill: None, ..Style::default_for(&shape) };
|
||||
assert!(h.state_mut().apply_edit(Edit::AddAnnotation(NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
style,
|
||||
contents: "Faint".into(),
|
||||
author: "Tester".into()
|
||||
})));
|
||||
h.run_steps(2);
|
||||
let index = comments(&h).iter().find(|a| a.contents.as_deref() == Some("Faint")).map(|a| a.index).expect("the text box");
|
||||
// As the automation's comment_edit sets it.
|
||||
assert!(h.state_mut().apply_edit(Edit::PartOpacity { page: 0, index, fill: None, text: Some(0.5) }));
|
||||
h.run_steps(2);
|
||||
let opacity = |h: &Harness<'static, PdfCraftApp>| h.state().comment_prefs.style(CommentTool::TextBox).opacity;
|
||||
assert!((opacity(&h) - 1.0).abs() < 1e-6);
|
||||
h.state_mut().open_comment_props(0, index);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Make Properties Default").click();
|
||||
h.run_steps(1);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h));
|
||||
// The context menu's way, from a tool at 100 % again.
|
||||
h.state_mut().comment_prefs.set_opacity(CommentTool::TextBox, 1.0);
|
||||
h.state_mut().make_comment_default(0, index);
|
||||
assert!((opacity(&h) - 0.5).abs() < 1e-6, "the text's 50 %: {}", opacity(&h));
|
||||
}
|
||||
|
||||
/// #1302: making an arrow's properties the default updates the Arrow tool, not later plain lines.
|
||||
#[test]
|
||||
fn an_arrows_default_does_not_restyle_lines() {
|
||||
@@ -2915,7 +3110,14 @@ fn a_moved_comment_is_drawn_where_it_goes_before_release() {
|
||||
for shape in
|
||||
[pdfcraft_engine::Shape::Rectangle { rect: [30.0, 30.0, 70.0, 70.0] }, pdfcraft_engine::Shape::Oval { rect: [230.0, 30.0, 270.0, 70.0] }]
|
||||
{
|
||||
let style = pdfcraft_engine::Style { color: [1.0, 0.0, 0.0], opacity: 1.0, width: 2.0, fill: Some([1.0, 0.0, 0.0]) };
|
||||
let style = pdfcraft_engine::Style {
|
||||
color: [1.0, 0.0, 0.0],
|
||||
opacity: 1.0,
|
||||
width: 2.0,
|
||||
fill: Some([1.0, 0.0, 0.0]),
|
||||
fill_opacity: None,
|
||||
text_opacity: None,
|
||||
};
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation {
|
||||
page: 0,
|
||||
shape,
|
||||
|
||||
@@ -911,6 +911,28 @@ fn comment_style_settings_clamp_and_refuse_hostile_values() {
|
||||
assert_eq!(note.color, [1.0, 0.82, 0.0], "the default colour stays");
|
||||
}
|
||||
|
||||
/// A tool's fill's and text's own opacities survive a restart, clamped; a tool without a fill or
|
||||
/// text keeps none, and the quick bar's Opacity (the whole comment's) clears them.
|
||||
#[test]
|
||||
fn part_opacity_defaults_persist_and_clamp() {
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
let mut a = PdfCraftApp::new();
|
||||
a.comment_prefs.set_part_opacity(CommentTool::Rectangle, Some(0.3), None);
|
||||
a.comment_prefs.set_part_opacity(CommentTool::TextBox, Some(0.2), Some(1.0));
|
||||
a.comment_prefs.set_part_opacity(CommentTool::Line, Some(0.5), Some(0.5));
|
||||
let mut b = PdfCraftApp::new();
|
||||
b.restore(&a.persist());
|
||||
let parts = |app: &PdfCraftApp, tool| {
|
||||
let s = app.comment_prefs.style(tool);
|
||||
(s.fill_opacity, s.text_opacity)
|
||||
};
|
||||
assert_eq!(parts(&b, CommentTool::Rectangle), (Some(0.3), None));
|
||||
assert_eq!(parts(&b, CommentTool::TextBox), (Some(0.2), Some(1.0)));
|
||||
assert_eq!(parts(&b, CommentTool::Line), (None, None), "a line has no fill or text of its own");
|
||||
b.restore(r#"{"comment_styles":[{"tool":"comment.freetext","fill_opacity":7,"text_opacity":-1}]}"#);
|
||||
assert_eq!(parts(&b, CommentTool::TextBox), (Some(1.0), Some(0.1)), "clamped; the text stays visible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_keeps_the_point_under_the_cursor_still() {
|
||||
let mut h = harness(|app| {
|
||||
|
||||
@@ -1484,6 +1484,138 @@ fn up_and_down_turn_pages_in_single_page_view() {
|
||||
assert_eq!(press(&mut h, Key::ArrowDown, 1), 0, "scrolled up from the bottom of page 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_and_right_scroll_a_page_wider_than_the_window_before_turning_it() {
|
||||
// A plan zoomed in: → / ← turned the page (#185) and so never showed its other side; the
|
||||
// view moved sideways only by the scroll bar, the wheel or the Hand.
|
||||
use egui::{Key, Modifiers};
|
||||
let mut h = harness();
|
||||
h.state_mut().active = Some(0);
|
||||
h.state_mut().views[0].set_zoom(12.0);
|
||||
h.run_steps(6);
|
||||
let press = |h: &mut Harness<'static, PdfCraftApp>, modifiers, key| {
|
||||
h.key_press_modifiers(modifiers, key);
|
||||
h.run_steps(4);
|
||||
let v = &h.state().views[0];
|
||||
(v.current, v.page_screen_rect(v.current).map(|r| r.left()))
|
||||
};
|
||||
let start = h.state().views[0].page_screen_rect(0).map(|r| r.left()).expect("on screen");
|
||||
let (page, left) = press(&mut h, Modifiers::NONE, Key::ArrowRight);
|
||||
assert_eq!(page, 0, "→ scrolls the wide page rather than turning it");
|
||||
assert!(left.is_some_and(|l| l < start - 10.0), "the page moved left: {left:?} from {start}");
|
||||
// At its right edge the next → turns the page.
|
||||
let mut n = 1;
|
||||
while press(&mut h, Modifiers::NONE, Key::ArrowRight).0 == 0 {
|
||||
n += 1;
|
||||
assert!(n < 500, "→ never reached the right edge");
|
||||
}
|
||||
assert!(n > 3, "→ scrolled across page 1 before turning ({n} presses)");
|
||||
assert_eq!(h.state().views[0].current, 1);
|
||||
// Page 2 starts at its left edge, as reading on across: → scrolls it rather than turning
|
||||
// again at once.
|
||||
let shown = |h: &Harness<'static, PdfCraftApp>, page: usize| {
|
||||
let v = &h.state().views[0];
|
||||
(v.page_screen_rect(page).expect("on screen"), v.viewport_rect())
|
||||
};
|
||||
let (r, view) = shown(&h, 1);
|
||||
assert!(r.left() >= view.left() && r.right() > view.right(), "page 2 from its left edge: {r:?} in {view:?}");
|
||||
assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 1, "→ scrolls page 2 from its left edge");
|
||||
// ← goes back to that edge and turns back: page 1 comes at its right edge, so the next ← scrolls it.
|
||||
let mut n = 1;
|
||||
while press(&mut h, Modifiers::NONE, Key::ArrowLeft).0 == 1 {
|
||||
n += 1;
|
||||
assert!(n < 50, "← never turned back from page 2");
|
||||
}
|
||||
assert_eq!(h.state().views[0].current, 0);
|
||||
let (r, view) = shown(&h, 0);
|
||||
assert!(r.right() <= view.right() && r.left() < view.left(), "page 1 from its right edge: {r:?} in {view:?}");
|
||||
assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowLeft).0, 0, "← scrolls page 1 from its right edge");
|
||||
// ⌘→ turns at once.
|
||||
assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 1, "⌘→ turns the page at once");
|
||||
assert_eq!(press(&mut h, Modifiers::COMMAND, Key::ArrowRight).0, 2);
|
||||
// A page that fits across: → turns pages as before.
|
||||
h.state_mut().views[0].fit = pdfcraft_ui_egui::canvas::Fit::Width;
|
||||
h.run_steps(6);
|
||||
assert_eq!(press(&mut h, Modifiers::NONE, Key::ArrowRight).0, 3);
|
||||
}
|
||||
|
||||
/// Continuous view with pages of different widths: the content is as wide as the widest page, but
|
||||
/// on a narrow page that shows whole → turns the page; on the wide one it scrolls first.
|
||||
#[test]
|
||||
fn left_and_right_follow_the_current_pages_width_in_continuous_view() {
|
||||
use egui::Key;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("mixed.pdf", None, sized_fixture(&[(200, 1200), (3000, 1200), (200, 1200)])).unwrap();
|
||||
app
|
||||
});
|
||||
crate::support::settle(&mut h);
|
||||
h.state_mut().active = Some(0);
|
||||
assert!(h.state().views[0].continuous);
|
||||
// Each page taller than the window, so one is the current page.
|
||||
h.state_mut().views[0].set_zoom(1.0);
|
||||
h.run_steps(6);
|
||||
h.state_mut().views[0].go_to_page(0);
|
||||
h.run_steps(6);
|
||||
let press = |h: &mut Harness<'static, PdfCraftApp>| {
|
||||
h.key_press(Key::ArrowRight);
|
||||
h.run_steps(4);
|
||||
let v = &h.state().views[0];
|
||||
(v.current, v.page_screen_rect(1).map(|r| r.left()))
|
||||
};
|
||||
assert_eq!(h.state().views[0].current, 0);
|
||||
let (page, start) = press(&mut h);
|
||||
assert_eq!(page, 1, "→ on the narrow page shown whole turns it");
|
||||
let (page, left) = press(&mut h);
|
||||
assert_eq!(page, 1, "→ on the wide page scrolls it");
|
||||
assert!(left.zip(start).is_some_and(|(l, s)| l < s - 10.0), "the wide page moved left: {left:?} from {start:?}");
|
||||
}
|
||||
|
||||
/// A zoomed two-page spread scrolled so its first page ends inside the window and the second is
|
||||
/// still wholly to the right of it: → scrolls on to the second page rather than turning the
|
||||
/// spread. (Read from the pages on screen alone, the spread seemed to end with its first page.)
|
||||
#[test]
|
||||
fn right_scrolls_to_a_spreads_second_page_off_screen_before_turning() {
|
||||
use egui::Key;
|
||||
// The page gap (points on screen).
|
||||
const GAP: f32 = 14.0;
|
||||
let build = |zoom: f32| {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("spread.pdf", None, sized_fixture(&[(600, 800); 4])).unwrap();
|
||||
app.set_option("layout", "two-page").unwrap();
|
||||
// Before the first layout: zoomed about the content's top left, scrolled to it.
|
||||
app.views[0].set_zoom(zoom);
|
||||
app
|
||||
});
|
||||
crate::support::settle(&mut h);
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(4);
|
||||
h
|
||||
};
|
||||
let shown =
|
||||
|h: &Harness<'static, PdfCraftApp>| (h.state().views[0].viewport_rect(), h.state().views[0].page_screen_rect(0).expect("page 1 on screen"));
|
||||
// Zoom until page 1's right edge sits half a gap inside the window's.
|
||||
let mut zoom = 2.0;
|
||||
let mut h = build(zoom);
|
||||
for _ in 0..3 {
|
||||
let (inner, first) = shown(&h);
|
||||
zoom *= (inner.right() - GAP / 2.0 - first.left()) / first.width();
|
||||
h = build(zoom);
|
||||
}
|
||||
let (inner, first) = shown(&h);
|
||||
assert!(first.right() <= inner.right() && first.right() + GAP > inner.right(), "the spread's gap at the window's edge: {first:?} in {inner:?}");
|
||||
assert!(h.state().views[0].page_screen_rect(1).is_none(), "its second page off screen");
|
||||
assert!(h.state().views[0].layout == pdfcraft_ui_egui::canvas::PageLayout::TwoUp && !h.state().views[0].continuous);
|
||||
h.key_press(Key::ArrowRight);
|
||||
h.run_steps(4);
|
||||
let v = &h.state().views[0];
|
||||
assert!(v.current < 2, "→ doesn't turn the spread: page {}", v.current + 1);
|
||||
assert!(v.page_screen_rect(1).is_some(), "it scrolls the second page into view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v_h_and_space_pick_the_quick_tools() {
|
||||
// The toolbar's tooltips promise "Select (V)" and "Hand (H)", but the keys did nothing.
|
||||
|
||||
@@ -149,7 +149,7 @@ fn values(doc: &Document) -> Vec<(String, Vec<String>)> {
|
||||
fn a_replace_text_pair_survives_an_xfdf_round_trip() {
|
||||
let mut src = blank();
|
||||
let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]];
|
||||
let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None };
|
||||
let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None, ..Style::default() };
|
||||
let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() };
|
||||
add_text_replacement(
|
||||
&mut src,
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (Pages inspector thumbnail size slider and buttons #3808; full screen presents one page at a time #1459; the selected field nudges with the arrow keys in Prepare a form #2608) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-24 · **Change:** minor (→ / ← scroll a page wider than the window before turning it; a comment's text, border or line, and fill each at an opacity of its own) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
How PdfCraft's tools, panels, shortcuts and feel compare with Acrobat Pro's current interface (the
|
||||
2023+ "new Acrobat": All tools pane on the left, quick-action toolbar, Home view). Behaviour is
|
||||
@@ -44,11 +44,12 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
|
||||
| Zoom: wheel, pinch, keys, marquee, fit page/width/height/visible | yes | yes | shipped |
|
||||
| Fixed zoom percentages match Acrobat's physical size | yes | differ (#739, documented in #736) | gap |
|
||||
| Dynamic zoom, loupe, pan-and-zoom window | yes | no | planned |
|
||||
| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll | partial |
|
||||
| Hand tool, middle-button pan, autoscroll | yes | hand tool; Linux autoscroll; → / ← scroll a page wider than the window before turning it | partial |
|
||||
| Text selection, column select | yes, Alt-drag columns | reading-order selection; Alt/Option-drag or Edit ▸ Column select (#740) | shipped |
|
||||
| Select and move several objects at once | yes | on one page: Shift/Ctrl/⌘-click and marquee, one undo step (#844); not across pages, no group resize | partial |
|
||||
| Moved comments drawn where they go while dragged | yes | yes: the comment's own pixels follow the pointer, the page shows it where it was until release; stamps and Fill & Sign marks also hide the original | shipped |
|
||||
| Object handles: corners keep proportion, edges free | yes | yes for added images and signatures; Shift on a corner keeps the proportions of comments, stamps and form fields (#2199); no Alt resize from the centre | shipped |
|
||||
| Shift while drawing a line | holds it to 45° steps | holds it level or plumb (a CAD drawing's ortho): new lines and arrows, each side of connected lines, polygons and clouds, and a dragged end or corner | shipped |
|
||||
| Arrange, align and distribute page objects | yes | fields only | partial |
|
||||
| Rulers, grids, snap to grid, guides | yes | no | planned |
|
||||
| Cursor coordinates, line weights | yes | no | planned |
|
||||
@@ -75,13 +76,15 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24)
|
||||
| Print dialog with preview | yes | yes | shipped |
|
||||
| Document Properties (description, security, fonts, initial view, custom, advanced) | yes | all but Custom and XMP | partial |
|
||||
| Field Properties (General, Appearance, Position, Options, Actions, Format, Validate, Calculate, Signed) | yes | all but Signed | partial |
|
||||
| Comment properties and properties bar | yes | yes | shipped |
|
||||
| Comment properties and properties bar | yes | yes; a text box's text, border and fill and a shape's line and fill each take an opacity of their own (Acrobat: one per comment) | shipped |
|
||||
| Preferences | ≈ 20 pages | Interface language, Documents and view, Identity, Fill & Sign, JavaScript | partial (gap 19) |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | minor | → / ← scroll a page wider than the window sideways before turning it, by the current page's or spread's edges in continuous view; ⌘→ / ⌘← turn at once |
|
||||
| 2026-10-24 | minor | Comment Properties sets a text box's text, border and fill opacity and a shape's line and fill opacity each on its own (Acrobat: one per comment); Make Properties Default carries them into the tool |
|
||||
| 2026-10-21 | minor | Pages inspector thumbnail sizing (#3808), separate from page and Organize zoom; view setting retained on reload and exposed through UI control. Not persisted between sessions. |
|
||||
| 2026-10-15 | minor | A selected field nudges with the arrow keys in Prepare a form: a point, Shift ten points, one undo step per press (#2608) |
|
||||
| 2026-10-14 | minor | Full screen shows one fitted page (or spread) at a time on black with click and key paging, as Acrobat's Full Screen Mode (#379, #1459, #1353) |
|
||||
|
||||
@@ -78,6 +78,64 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
|
||||
downloads) are blocked.
|
||||
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
|
||||
|
||||
## Same-document host mode (no iframe)
|
||||
|
||||
A host application can also run PdfCraft from its own document: it loads the wasm-bindgen module
|
||||
itself and gives PdfCraft one of its own canvases, so there is no iframe and no `?host=parent`.
|
||||
It opts in with `data-host="page"` on that canvas — the exact value `page` — and the canvas
|
||||
keeps the id the app looks up (`id="pdfcraft"`).
|
||||
|
||||
The page and the app then talk with DOM `CustomEvent`s on the canvas instead of `postMessage`.
|
||||
The app dispatches the same messages it would post to a parent — `pdfcraft:ready`,
|
||||
`pdfcraft:save`, `pdfcraft:dirty`, `pdfcraft:failed` — with the message fields (without the
|
||||
`type`) as the event's `detail`, and it opens a file when the page dispatches a `pdfcraft:open`
|
||||
event with `detail: { name, bytes }`. Events do not bubble, so listen on the canvas itself.
|
||||
|
||||
Add the listeners before `init`: the app announces itself with `pdfcraft:ready` while `init` is
|
||||
still running, and a file sent in reply would otherwise be missed.
|
||||
|
||||
```js
|
||||
import init from "./pdfcraft-web-<hash>.js"; // the wasm-bindgen glue shipped with the app
|
||||
|
||||
const canvas = document.getElementById("pdfcraft");
|
||||
canvas.dataset.host = "page"; // or <canvas id="pdfcraft" data-host="page">
|
||||
|
||||
canvas.addEventListener("pdfcraft:ready", () => {
|
||||
// The app is listening; hand it a document now, if you have one.
|
||||
canvas.dispatchEvent(new CustomEvent("pdfcraft:open", {
|
||||
detail: { name: "handout.pdf", bytes: pdfBytes }, // ArrayBuffer or Uint8Array
|
||||
}));
|
||||
});
|
||||
canvas.addEventListener("pdfcraft:save", (event) => {
|
||||
const { name, saveAs, bytes } = event.detail; // bytes is an ArrayBuffer
|
||||
// Store the file under `name`; `saveAs` is true for Save As. Report your own errors.
|
||||
});
|
||||
canvas.addEventListener("pdfcraft:dirty", (event) => {
|
||||
unsaved = event.detail.dirty; // warn before leaving, as you like
|
||||
});
|
||||
canvas.addEventListener("pdfcraft:failed", (event) => {
|
||||
showError(event.detail.error); // the app couldn't start
|
||||
});
|
||||
|
||||
await init({ module_or_path: "./pdfcraft-web-<hash>_bg.wasm" });
|
||||
```
|
||||
|
||||
- Saves are not downloads in this mode: the `bytes` in `pdfcraft:save` hold the saved PDF, and
|
||||
the host decides where the file goes.
|
||||
- `?author=<name>` still signs the user's comments, as with `?host=parent`.
|
||||
- An unframed page that uses `?host=parent` keeps the normal app with downloads.
|
||||
`data-host="page"` turns this mode on only when `?host=parent` is absent: if `?host=parent` is
|
||||
present it takes precedence, so a same-origin framed page uses the parent bridge, while an
|
||||
unframed or cross-origin page gets no bridge and keeps normal downloads.
|
||||
|
||||
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `pdfcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. PdfCraft reads bytes, so a host sends `bytes`, not a `File`.
|
||||
|
||||
### Trust and limits
|
||||
|
||||
- **Who is trusted.** In page host mode the page and PdfCraft share one document, so any script on the page can drive the app: open files in it and receive what it saves. Use this mode only for a page you control. The iframe mode (`?host=parent`) answers only the parent window, on the page's own origin.
|
||||
- **Files from the host are untrusted input.** An opened file goes through the same reading as one picked with File ▸ Open or dropped on the window, with the same format limits; before it is copied into the app's memory, its size is checked against the host open limit (512 MiB) and a larger file is refused, and a name is cut to 255 characters.
|
||||
- **The event and message names are the interface.** `pdfcraft:ready`, `pdfcraft:open`, `pdfcraft:save`, `pdfcraft:dirty` and `pdfcraft:failed`, and the fields they carry (the same as the `?host=parent` messages), stay stable; changing one is a breaking change.
|
||||
|
||||
## Renderer selection and fallback flags
|
||||
|
||||
PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
|
||||
@@ -1154,8 +1154,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document"]
|
||||
notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout, ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel"
|
||||
evidence = ["crates/ui-egui/tests/it/control.rs::state_and_view_options", "crates/ui-egui/tests/it/ui.rs::theme_and_view_options_apply", "crates/ui-egui/tests/it/viewer.rs::two_page_view_steps_a_spread_at_a_time", "crates/ui-egui/tests/it/viewer.rs::arrow_and_page_keys_move_through_a_scrolling_document", "crates/ui-egui/tests/it/viewer.rs::left_and_right_scroll_a_page_wider_than_the_window_before_turning_it"]
|
||||
notes = "in two-page view, next and previous page move a whole spread (with or without a cover page); → / ← turn pages in every layout (on a page wider than the window, a plan zoomed in, they scroll sideways a line first and turn the page at its edge; ⌘→ / ⌘← turn at once), ↓ / ↑ scroll a line and Page Down / Page Up a screen (the page in single-page view), as in Acrobat; in single-page view ↓ / ↑ turn the page once there is nothing left to scroll that way, like the wheel"
|
||||
|
||||
[[feature]]
|
||||
id = "view.goto-page-label"
|
||||
@@ -3965,6 +3965,18 @@ tools = ["comment_edit"]
|
||||
evidence = ["crates/ui-egui/tests/it/comments.rs::comment_properties_change_appearance_and_author", "crates/annot/src/tests.rs::author_subject_and_note_icon_change", "crates/ui-egui/tests/it/comments.rs::shapes_take_a_fill_colour_from_the_quick_bar_and_properties", "crates/ui-egui/tests/it/comments.rs::text_box_properties_change_the_font_size", "crates/ui-egui/tests/it/comments.rs::text_box_and_callout_properties_include_fill_and_thickness", "crates/annot/src/tests.rs::text_boxes_and_callouts_can_change_fill_and_border", "crates/automation/tests/it/automation.rs::text_box_and_callout_fill_and_border_through_tools", "crates/annot/src/tests.rs::text_box_font_size_changes_the_da_and_grows_the_box", "crates/automation/tests/it/automation.rs::text_box_font_size_through_the_tool"]
|
||||
notes = "Appearance (colour, fill colour for rectangles/ovals/polygons/text boxes/callouts, opacity, thickness, note icon, font size of text boxes, typewriter text and callouts: 1-400 pt, written to /DA and the rich-text strings, the box grows to fit and never shrinks; comment_edit font_size), General (author, subject), Review History. Still missing: font family, bold/italic, alignment and per-run formatting of text boxes; the quick bar has no font size for new text boxes (they start at 12 pt, callouts 10 pt)"
|
||||
|
||||
[[feature]]
|
||||
id = "comment.part-opacity"
|
||||
title = "A comment's text, border or line, and fill each at an opacity of its own"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["comment_add", "comment_edit", "comment_list"]
|
||||
evidence = ["crates/annot/src/tests.rs::parts_are_drawn_at_opacities_of_their_own", "crates/ui-egui/tests/it/comments.rs::comment_properties_set_each_parts_opacity", "crates/automation/tests/it/automation.rs::comment_parts_take_opacities_of_their_own_through_the_tools", "crates/ui-egui/tests/it/ui.rs::part_opacity_defaults_persist_and_clamp"]
|
||||
notes = "Comment Properties shows an opacity for each part instead of one for the whole comment: a text box, typewriter text or callout its Text, Border and Fill opacity, a rectangle, oval, polygon or cloud its Line and Fill opacity (a part that isn't drawn, such as an unset fill, is greyed); lines, drawings and the rest keep one Opacity. A see-through fill with an opaque line marks an area on a plan without hiding it. Stored as the line's /CA, the fill's /ca (PDF 2.0's opacity for what is filled) and the text's /PCTextOpacity (PdfCraft's own), each only where it differs, and drawn with two graphics states in the appearance, so every viewer shows it; an app that redraws the comment from its keys (Acrobat after editing it) uses one opacity again. The quick bar's Opacity and comment_edit `opacity` set the whole comment's; Make Properties Default carries the parts into the tool (saved with the settings). Headless: comment_add / comment_edit `fill_opacity`, `text_opacity`; comment_list reports `opacity`, `fill_opacity`, `text_opacity`. Acrobat has one opacity per comment."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.properties-bar"
|
||||
title = "Comment properties bar"
|
||||
@@ -4095,7 +4107,19 @@ status = "shipped"
|
||||
commands = []
|
||||
tools = ["comment_edit"]
|
||||
evidence = ["crates/annot/src/tests.rs::lines_and_polygons_are_reshaped_by_their_points", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::comments_are_reshaped_through_the_tool"]
|
||||
notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it to 45° from the point before), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner."
|
||||
notes = "A selected line, arrow, connected lines, polygon or cloud has a handle on each end or corner (instead of the frame); dragging one changes the length and angle (Shift holds it level or plumb with the point before, comment.draw-ortho), with the new shape drawn while dragging, as one undo step (Edit::ReshapeAnnotation). The drawing tools keep drawing from an end (a new line starts there); the Select tool reshapes. /Rect follows the points with room for the width and arrowheads, and the appearance is drawn anew. Headless: comment_edit `points`; comment_list reports them. Measurements keep the points they measured; locked comments keep their shape. Not yet: adding or removing a corner."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.draw-ortho"
|
||||
title = "Shift holds lines level or plumb while drawing (ortho)"
|
||||
area = "E"
|
||||
tier = "P1"
|
||||
milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/measure/src/tests.rs::ortho_holds_a_vertex_level_or_plumb_with_the_last", "crates/ui-egui/tests/it/comments.rs::shift_holds_new_lines_and_sides_level_or_plumb", "crates/ui-egui/tests/it/comments.rs::the_rubber_band_shows_the_closing_side_with_shift_held", "crates/ui-egui/tests/it/comments.rs::line_ends_are_dragged_to_change_length_and_angle", "crates/automation/tests/it/automation.rs::lines_and_corners_are_held_level_or_plumb_through_the_tool"]
|
||||
notes = "With Shift held, a new line or arrow runs level or plumb from where it starts, whichever is nearer to the pointer (a CAD drawing's ortho mode), and each new side of connected lines, a polygon or a cloud from the corner before, the rubber band included. Over the first corner, where a click closes a polygon or cloud, the closing side runs straight to that corner, Shift or not. A dragged end or corner of a drawn one is held the same way (comment.reshape). Acrobat holds lines to 45° steps instead; on drawings and plans the level and plumb lines are the ones wanted. Headless: comment_add `ortho`. Measuring tools keep their 45° steps."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.move-preview"
|
||||
|
||||
Reference in new issue
Block a user