M7: a text editing session for one paragraph

A TextSession opens a paragraph once: it keeps the document as it was
and the page's lines then. Each draft is laid out on a copy of that
document, so no draft sees another's output, and the commit writes into
the document it was opened on, refusing ("the paragraph changed while it
was being edited") when that document has changed since.

text_edit_layout and rewrite_block now open a session and lay out or
commit once, so there is one path and their output is unchanged. No
caches yet: those come next, each under the same equivalence test.

First step of #2512.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
komo1iddin
2026-10-22 02:15:47 +08:00
co-authored by Claude Opus 5.5
parent 4164951453
commit 6495c08afd
4 changed files with 126 additions and 9 deletions
+1 -1
View File
@@ -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;
+37
View File
@@ -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));
}
+19 -8
View File
@@ -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<TextEditLayout, EditError> {
// 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<TextEditLayout, EditError> {
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<LineEdit, EditError> {
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.
+69
View File
@@ -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<TextLine>,
source: TextBlock,
}
impl TextSession {
/// Opens paragraph `block` on `page` (both 0-based) of `doc`.
pub fn open(doc: &Document, page: usize, block: usize) -> Result<Self, EditError> {
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<TextEditLayout, EditError> {
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<LineEdit, EditError> {
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<LineEdit, EditError> {
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)
}
}