mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 16:32:04 +00:00
* A comment's text, border or line, and fill each at an opacity of its own
Comment Properties showed one opacity for a whole comment, so a text box's
see-through background took its text and border with it, and a rectangle
marking an area on a plan couldn't have an opaque line over a faint fill.
Now a text box, typewriter text or callout has a Text, Border and Fill
opacity, a rectangle, oval, polygon or cloud a Line and Fill opacity (a part
not drawn is greyed); everything else keeps one Opacity.
The line's stays /CA, the fill's is /ca (PDF 2.0's opacity for what is
filled) and the text's /PCTextOpacity, each only where it differs from the
one it would follow, and the appearance draws them with /GS0 (strokes at the
line's, fills at the fill's) and /GS1 for the text, so every viewer shows
them. The quick bar's Opacity and StyleAnnotation's set the whole comment's.
Engine: Style::{fill_opacity, text_opacity}, Edit::PartOpacity,
annot::{set_part_opacity, appearance::part_opacity}, Props::{fill_opacity,
text_opacity}. Make Properties Default and the settings carry the parts.
Headless: comment_add / comment_edit fill_opacity, text_opacity; comment_list
reports opacity, fill_opacity, text_opacity. Four labels in every catalog
(AI-assisted translations from English, noted in ATTRIBUTION.toml).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Review: the text's opacity slider shows what the text is drawn at; comment_list skips replies' properties; ui-parity's change record
A text box without fill and border whose text kept an opacity of its own showed the border's
in its Text opacity slider; it now shows the text's, and a change sets the whole comment's.
The opacity sliders are named by their labels. comment_list reads a comment's properties only
for comments, not for every reply.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rebase: comment_list tells replies by is_reply, as main now threads them
main threads replies by their /IRT reference (#4061); the opacity fields skip a reply the
same way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Review: a text-only text box made default gives the tool its text's opacity
With neither a border nor a fill, a text box's text is the whole comment:
Comment Properties shows its drawn opacity (the text's own, when set), and a
change there sets the whole comment's. Make Properties Default, and the
context menu's Make Current Properties Default, stored /CA instead, so a
text box whose text was at 50 % gave the tool 100 %. The default is now the
text's drawn opacity in that case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rebase: the part opacities in main's annot layout (Style's fields, the text box drawing's parts, set_part_opacity exported)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rebase: main's new comment test builds a Style with the part opacities
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
237 lines
10 KiB
Rust
237 lines
10 KiB
Rust
//! Undo/redo: the editing state of a document, its snapshots and their labels.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::{AppearanceCache, Edit, EditError, Shape};
|
|
|
|
/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap.
|
|
pub(crate) const MAX_UNDO: usize = 100;
|
|
|
|
/// The passwords that go with one state of a document (protection can change them).
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub(crate) struct Keys {
|
|
/// What the renderer and inspector open the working file with (the user password).
|
|
pub(crate) render: Option<String>,
|
|
/// What the editor re-opens a saved file with: the strongest password known (owner if any).
|
|
pub(crate) reopen: Option<String>,
|
|
}
|
|
|
|
/// What an edit can change, and so what the view data must be rebuilt from.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub(crate) enum Scope {
|
|
/// Anything: re-inspect the whole document.
|
|
Full,
|
|
/// Document-information entries; page content and appearances are unchanged.
|
|
Metadata,
|
|
/// Only comments: re-read the comment list from the object graph.
|
|
Comments,
|
|
/// Only form field values (and their widget appearances).
|
|
Form,
|
|
/// Link annotations; the full inspection is refreshed, but page-content indexes stay valid.
|
|
Links,
|
|
/// Only page content streams, resources and added content/marks: the page tree,
|
|
/// annotations, form fields, links, outline and fonts are unchanged, so the full
|
|
/// re-inspection is skipped (seconds on very large files).
|
|
Content,
|
|
}
|
|
|
|
/// Edits during which field scripts run.
|
|
pub(crate) fn uses_scripts(edit: &Edit) -> bool {
|
|
match edit {
|
|
Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true,
|
|
Edit::Batch { edits, .. } => edits.iter().any(uses_scripts),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
|
match edit {
|
|
Edit::SetInfo { .. } => Scope::Metadata,
|
|
// The Attachments list is read from the whole document.
|
|
Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full,
|
|
// A file attachment also changes the Attachments list.
|
|
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
|
|
Edit::AddMeasurement(_)
|
|
| Edit::AddAnnotation(_)
|
|
| Edit::AddCustomStamp { .. }
|
|
| Edit::AddModel3d { .. }
|
|
| Edit::DeleteAnnotation { .. }
|
|
| Edit::SetAnnotationContents { .. }
|
|
| Edit::ReplyToAnnotation { .. }
|
|
| Edit::SetAnnotationStatus { .. }
|
|
| Edit::MarkAnnotation { .. }
|
|
| Edit::LockAnnotation { .. }
|
|
| Edit::ReplaceText { .. }
|
|
| Edit::EraseInk { .. }
|
|
| Edit::MoveAnnotation { .. }
|
|
| Edit::ReshapeAnnotation { .. }
|
|
| 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,
|
|
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
|
|
// Page content, resources and added content/marks only: the page tree, annotations,
|
|
// fields, links and document metadata are untouched (see `refresh_content`).
|
|
Edit::MoveObjects { .. }
|
|
| Edit::EditTextLine { .. }
|
|
| Edit::EditTextBlock { .. }
|
|
| Edit::EditPageImage { .. }
|
|
| Edit::EnhanceScan { .. }
|
|
| Edit::AddText { .. }
|
|
| Edit::AddImage { .. }
|
|
| Edit::UpdateContent { .. }
|
|
| Edit::DeleteContent { .. }
|
|
| Edit::ReplaceImage { .. }
|
|
| Edit::PasteContent { .. }
|
|
| Edit::AddHeaderFooter { .. }
|
|
| Edit::AddWatermark { .. }
|
|
| Edit::AddBackground { .. }
|
|
| Edit::RemoveMarks { .. } => Scope::Content,
|
|
Edit::Batch { edits, .. } => {
|
|
let mut scopes = edits.iter().map(scope_of);
|
|
let first = scopes.next().unwrap_or(Scope::Full);
|
|
if scopes.all(|s| s == first) { first } else { Scope::Full }
|
|
}
|
|
_ => Scope::Full,
|
|
}
|
|
}
|
|
|
|
/// One undo/redo step: its label, the document state, its passwords, and what it changed.
|
|
pub(crate) type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope);
|
|
|
|
/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted).
|
|
#[derive(Clone)]
|
|
pub(crate) struct Editor {
|
|
pub(crate) cos: pdfcraft_cos::Document,
|
|
/// The file as last opened or saved. Incremental saves append to it, and Revert opens it.
|
|
/// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it.
|
|
pub(crate) saved: Arc<Vec<u8>>,
|
|
/// Passwords that open [`Self::saved`].
|
|
pub(crate) saved_keys: Keys,
|
|
/// [`Self::saved`] was never written where the document belongs: a new document (combine,
|
|
/// extract, create) or one restored from recovery. It stays unsaved until a save, whatever
|
|
/// undo brings back, or closing it would lose the whole document without asking.
|
|
pub(crate) unsaved_file: bool,
|
|
pub(crate) undo: Vec<Snapshot>,
|
|
pub(crate) redo: Vec<Snapshot>,
|
|
pub(crate) keys: Keys,
|
|
/// Display appearances built for annotations without any, reused while no annotation edit
|
|
/// runs (content edits leave every annotation dictionary alone). Cleared on any refresh
|
|
/// that may change annotations; see [`AppearanceCache`].
|
|
pub(crate) appearances: AppearanceCache,
|
|
/// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused
|
|
/// for each widget while the objects it was drawn from are unchanged; see
|
|
/// [`pdfcraft_forms::FieldAppearanceCache`].
|
|
pub(crate) field_appearances: pdfcraft_forms::FieldAppearanceCache,
|
|
/// The text blocks last computed for one page, reused while the document is unchanged.
|
|
pub(crate) text_blocks: TextBlocksCache,
|
|
}
|
|
|
|
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read
|
|
/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not
|
|
/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns
|
|
/// with undo, so a stale entry never matches, also after the document has been edited.
|
|
#[derive(Default)]
|
|
pub(crate) struct TextBlocksCache(std::sync::Mutex<Option<CachedTextBlocks>>);
|
|
|
|
/// The document state, the page (0-based) and its text blocks.
|
|
type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
|
|
|
|
impl Clone for TextBlocksCache {
|
|
fn clone(&self) -> Self {
|
|
// A poisoned cache only loses the cached blocks.
|
|
Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone())))
|
|
}
|
|
}
|
|
|
|
impl Editor {
|
|
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
|
|
pub(crate) fn text_blocks(&self, page: usize) -> Result<Arc<Vec<pdfcraft_edit::TextBlock>>, EditError> {
|
|
if let Ok(cache) = self.text_blocks.0.lock()
|
|
&& let Some((state, p, blocks)) = cache.as_ref()
|
|
&& *p == page
|
|
&& state.matches(&self.cos)
|
|
{
|
|
return Ok(blocks.clone());
|
|
}
|
|
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
|
|
if let Ok(mut cache) = self.text_blocks.0.lock() {
|
|
*cache = Some((self.cos.read_state(), page, blocks.clone()));
|
|
}
|
|
Ok(blocks)
|
|
}
|
|
}
|
|
|
|
/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts:
|
|
/// a whole English wording, and the one value the document itself supplies for the wording's hole
|
|
/// (a file name, a field name). An interface in another language looks the wording up and says the
|
|
/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so
|
|
/// the two cannot drift apart.
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
pub struct Label {
|
|
/// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`](crate::EDIT_LABELS).
|
|
pub wording: &'static str,
|
|
/// What the document supplies, when the wording has a hole for it.
|
|
pub value: Option<String>,
|
|
text: String,
|
|
}
|
|
|
|
impl Label {
|
|
/// A wording with nothing to fill in.
|
|
pub fn of(wording: &'static str) -> Self {
|
|
Self { wording, value: None, text: wording.to_string() }
|
|
}
|
|
|
|
/// A wording whose hole the document fills in.
|
|
pub fn with(wording: &'static str, value: impl Into<String>) -> Self {
|
|
let mut label = Self { wording, value: Some(value.into()), text: String::new() };
|
|
label.text = label.said(wording);
|
|
label
|
|
}
|
|
|
|
/// The label read with this wording: the interface's translation of [`wording`](Self::wording),
|
|
/// or the English wording itself. What the document supplies goes in as it stands and is not
|
|
/// read again, so a name with braces in it stays whole.
|
|
pub fn said(&self, wording: &str) -> String {
|
|
let Some(value) = &self.value else { return wording.to_owned() };
|
|
// `{` and `}` are ASCII, so these are char boundaries.
|
|
match (wording.find('{'), wording.find('}')) {
|
|
(Some(open), Some(close)) if open < close => {
|
|
let mut said = String::with_capacity(wording.len() + value.len());
|
|
said.push_str(&wording[..open]);
|
|
said.push_str(value);
|
|
said.push_str(&wording[close + 1..]);
|
|
said
|
|
}
|
|
// A translation that lost its hole is said as it stands rather than losing the value.
|
|
_ => wording.to_owned(),
|
|
}
|
|
}
|
|
|
|
/// The label as English reads it.
|
|
pub fn as_str(&self) -> &str {
|
|
&self.text
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for Label {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(&self.text)
|
|
}
|
|
}
|
|
|
|
/// The passwords after `edit`, if it changes them.
|
|
pub(crate) fn keys_after(edit: &Edit) -> Option<Keys> {
|
|
match edit {
|
|
Edit::Protect(p) => {
|
|
Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) })
|
|
}
|
|
Edit::RemoveProtection => Some(Keys::default()),
|
|
Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after),
|
|
_ => None,
|
|
}
|
|
}
|