diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index c1062b410..d2e4b29ae 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -859,7 +859,7 @@ pub mod indic; mod linebreak; pub mod text; pub use text::{ - BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, clear_block, reading_blocks, + BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, clear_block, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; pub mod added; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 879ebc6c6..31164e1a3 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -5218,3 +5218,40 @@ fn a_clipping_copy_on_the_same_baseline_is_refused() { assert!(matches!(&block, Err(EditError::Invalid(m)) if m.contains("clipping path")), "{block:?}"); assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 2, "refused means untouched"); } + +/// #2512: a session's drafts are laid out from the document as it was opened, never from each +/// other, and its commit writes what a one-shot rewrite writes, whatever was laid out before. +#[test] +fn a_text_session_commits_what_a_rewrite_writes_whatever_it_laid_out_before() { + let page = "BT /F1 12 Tf 14 TL 72 700 Td (The first row of a paragraph) Tj T* (and its second row here.) Tj ET"; + let bold = BlockStyle { bold: Some(true), ..Default::default() }; + let drafts: [(&str, &BlockStyle); 4] = [ + ("The first row of a paragraph\nand its second row here, with more words to wrap it onto a third row", &BlockStyle::default()), + ("Short", &bold), + ("The first row\nsecond", &BlockStyle::default()), + ("The first row of a paragraph\nand its second row here.", &bold), + ]; + let doc = text_page(page); + let session = text::TextSession::open(&doc, 0, 0).unwrap(); + assert_eq!(session.source().editor_text, "The first row of a paragraph\nand its second row here."); + for (text, style) in drafts { + let fresh = text::text_edit_layout(&text_page(page), 0, 0, text, style).unwrap(); + assert_eq!(session.layout(text, style).unwrap(), fresh, "{text:?} is laid out from the opened page"); + } + let (text, style) = drafts[0]; + let mut by_session = doc.clone(); + session.commit(&mut by_session, Some(text), style).unwrap(); + let mut by_rewrite = text_page(page); + text::rewrite_block(&mut by_rewrite, 0, 0, Some(text), style).unwrap(); + assert_eq!( + pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(), + pdfcraft_cos::write_full(&by_rewrite, &SaveOptions::default()).unwrap(), + "the commit writes what a rewrite writes" + ); + // A document changed since the session opened isn't written to. + let before = pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(); + let refused = session.commit(&mut by_session, Some("Again"), &BlockStyle::default()); + assert!(matches!(&refused, Err(EditError::Invalid(m)) if m.contains("changed while it was being edited")), "{refused:?}"); + assert_eq!(pdfcraft_cos::write_full(&by_session, &SaveOptions::default()).unwrap(), before); + assert!(session.is_current(&doc) && !session.is_current(&by_session)); +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 267259217..afeadc80d 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -22,7 +22,10 @@ use crate::EditError; #[path = "text_regions.rs"] mod regions; +#[path = "text_session.rs"] +mod session; use regions::{group_blocks, rewrite_styled_block}; +pub use session::TextSession; /// One line of existing text. #[derive(Clone, Debug, PartialEq)] @@ -152,11 +155,20 @@ pub struct TextEditLayout { } pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, style: &BlockStyle) -> Result { - // This runs on every keystroke: read the page once for the paragraphs, the untouched runs - // and the rewrite, which starts from the same unchanged page. - let lines = text_lines(doc, page)?; - let original = group_blocks(&lines); - let source = original.get(block).ok_or(EditError::Invalid("text block does not exist".into()))?; + TextSession::open(doc, page, block)?.layout(text, style) +} + +/// A draft of paragraph `source` (number `block` among the page's `lines`) laid out by the rewrite +/// Apply runs, on a copy of `doc`. +fn layout_draft( + doc: &Document, + page: usize, + lines: &[TextLine], + source: &TextBlock, + block: usize, + text: &str, + style: &BlockStyle, +) -> Result { if text == source.editor_text && *style == BlockStyle::default() { return Ok(TextEditLayout { source_rect: source.rect, draft_rect: Some(source.rect), overflow: false }); } @@ -171,7 +183,7 @@ pub fn text_edit_layout(doc: &Document, page: usize, block: usize, text: &str, s } } let mut draft = doc.clone(); - rewrite_block_from(&mut draft, page, lines, block, Some(text), style, MAX_TYPE3_GLYPHS)?; + rewrite_block_from(&mut draft, page, lines.to_vec(), block, Some(text), style, MAX_TYPE3_GLYPHS)?; let mut rect: Option<[f64; 4]> = None; for line in text_lines(&draft, page)? { if let Some(count) = untouched.get_mut(&key(&line)) @@ -2189,8 +2201,7 @@ pub(crate) fn rewrite_block_with_limit( style: &BlockStyle, max_glyphs: usize, ) -> Result { - let lines = text_lines(doc, page)?; - rewrite_block_from(doc, page, lines, block, text, style, max_glyphs) + TextSession::open(doc, page, block)?.commit_with_limit(doc, text, style, max_glyphs) } /// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. diff --git a/crates/edit/src/text_session.rs b/crates/edit/src/text_session.rs new file mode 100644 index 000000000..893a0e4d2 --- /dev/null +++ b/crates/edit/src/text_session.rs @@ -0,0 +1,69 @@ +//! A text editing session for one paragraph (#2512). Opening it reads the page once; each draft +//! is laid out from that reading, on a copy of the document as it was opened, so no draft sees +//! another one's output; the commit writes into the document it was opened on, and refuses if +//! that document has changed since. + +use super::*; + +/// One paragraph being edited: the document as it was opened, and the page's lines then. +pub struct TextSession { + base: Document, + base_state: pdfcraft_cos::ReadState, + page: usize, + block: usize, + lines: Vec, + source: TextBlock, +} + +impl TextSession { + /// Opens paragraph `block` on `page` (both 0-based) of `doc`. + pub fn open(doc: &Document, page: usize, block: usize) -> Result { + let lines = text_lines(doc, page)?; + let source = group_blocks(&lines) + .get(block) + .cloned() + .ok_or_else(|| EditError::Invalid(format!("page {} has no paragraph {}", page + 1, block + 1)))?; + Ok(Self { base: doc.clone(), base_state: doc.read_state(), page, block, lines, source }) + } + + /// The paragraph as it was when the session opened. + pub fn source(&self) -> &TextBlock { + &self.source + } + + pub fn page(&self) -> usize { + self.page + } + + pub fn block(&self) -> usize { + self.block + } + + /// Whether `doc` is still the document the session was opened on (nothing applied since). + pub fn is_current(&self, doc: &Document) -> bool { + self.base_state.matches(doc) + } + + /// Lays a draft out without writing it: the rewrite Apply runs, on a copy of the opened document. + pub fn layout(&self, text: &str, style: &BlockStyle) -> Result { + layout_draft(&self.base, self.page, &self.lines, &self.source, self.block, text, style) + } + + /// Writes the draft into `doc`, which must still be the document the session was opened on. + pub fn commit(&self, doc: &mut Document, text: Option<&str>, style: &BlockStyle) -> Result { + self.commit_with_limit(doc, text, style, MAX_TYPE3_GLYPHS) + } + + pub(crate) fn commit_with_limit( + &self, + doc: &mut Document, + text: Option<&str>, + style: &BlockStyle, + max_glyphs: usize, + ) -> Result { + if !self.is_current(doc) { + return Err(EditError::Invalid("the paragraph changed while it was being edited".into())); + } + rewrite_block_from(doc, self.page, self.lines.clone(), self.block, text, style, max_glyphs) + } +}