mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-27 19:48:14 -06:00
This commit is contained in:
@@ -3463,6 +3463,27 @@ fn rich_region_edits_preserve_styles_positions_and_delete_all_runs_after_reopen(
|
||||
assert_eq!(text::text_blocks(&original, 0).unwrap().len(), 3);
|
||||
}
|
||||
|
||||
/// Two edits applied together (heading and footer edited before leaving the editor, #2198): the
|
||||
/// runs between them are unchanged and keep their own styles instead of taking the heading's.
|
||||
#[test]
|
||||
fn unchanged_runs_between_two_edits_keep_their_styles() {
|
||||
let original = mixed_region_page();
|
||||
let look =
|
||||
|doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| (l.text.trim().to_string(), l.base_font, l.color)).collect::<Vec<_>>();
|
||||
let before = look(&original);
|
||||
let block = text::text_blocks(&original, 0).unwrap()[1].clone();
|
||||
let mut doc = original.clone();
|
||||
let draft = block.editor_text.replace("About", "About us").replace("footer", "footnote");
|
||||
text::replace_block(&mut doc, 0, 1, &draft).unwrap();
|
||||
let after = look(&reopen(&doc));
|
||||
let style = |lines: &[(String, String, [f64; 3])], word: &str| lines.iter().find(|l| l.0 == word).map(|l| (l.1.clone(), l.2));
|
||||
for word in ["Plain", "bold", "text."] {
|
||||
assert_eq!(style(&after, word), style(&before, word), "{word:?} keeps its own style; lines after the edit: {after:?}");
|
||||
}
|
||||
assert_eq!(style(&after, "About us"), style(&before, "About"));
|
||||
assert_eq!(style(&after, "Italic footnote"), style(&before, "Italic footer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_style_ranges_preserve_unselected_runs_and_validate_utf8_atomically() {
|
||||
let mut doc = mixed_region_page();
|
||||
|
||||
+135
-14
@@ -270,17 +270,17 @@ pub struct TextBlock {
|
||||
}
|
||||
|
||||
impl TextBlock {
|
||||
/// Preserve styles outside the changed range; inserted characters inherit the surrounding
|
||||
/// source run. Byte ranges are always UTF-8 boundaries. Linear time, including large edits.
|
||||
/// Preserve styles outside the changed ranges; inserted characters inherit the surrounding
|
||||
/// source run. The texts are aligned word by word, so words left untouched between two edits
|
||||
/// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change.
|
||||
/// Byte ranges are always UTF-8 boundaries. Linear time, including large edits.
|
||||
pub fn edited_spans(&self, draft: &str) -> Vec<(std::ops::Range<usize>, usize)> {
|
||||
if self.editor_text.len() > 4_000_000 || draft.len() > 4_000_000 {
|
||||
return vec![(0..draft.len(), self.lines.first().copied().unwrap_or(0))];
|
||||
}
|
||||
let old: Vec<_> = self.editor_text.chars().collect();
|
||||
let new: Vec<_> = draft.char_indices().collect();
|
||||
let prefix = old.iter().zip(&new).take_while(|(a, (_, b))| *a == b).count();
|
||||
let suffix =
|
||||
old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, (_, b))| *a == b).count();
|
||||
let chars: Vec<_> = new.iter().map(|(_, c)| *c).collect();
|
||||
let mut original = Vec::with_capacity(old.len());
|
||||
let mut span = 0;
|
||||
for (byte, _) in self.editor_text.char_indices() {
|
||||
@@ -289,17 +289,36 @@ impl TextBlock {
|
||||
}
|
||||
original.push(self.spans.get(span).or_else(|| self.spans.last()).map_or(0, |(_, i)| *i));
|
||||
}
|
||||
let inherited = original.get(prefix.saturating_sub(1)).or_else(|| original.get(prefix)).copied().unwrap_or(0);
|
||||
let mut sources = Vec::with_capacity(new.len());
|
||||
let slices = |text: &[char]| words(text).into_iter().map(|w| text.get(w).unwrap_or_default().to_vec()).collect::<Vec<_>>();
|
||||
let (old_words, new_words) = (slices(&old), slices(&chars));
|
||||
match edit_script(&old_words, &new_words, MAX_CHANGED_WORDS) {
|
||||
Some(script) => {
|
||||
// `(o, n)`: where the current change starts in the old and new text; `(x, y)`: where it ends.
|
||||
let ((mut o, mut n), (mut x, mut y)) = ((0, 0), (0, 0));
|
||||
let (mut a, mut b) = (old_words.iter(), new_words.iter());
|
||||
for step in script {
|
||||
match step {
|
||||
Step::Keep => {
|
||||
replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources);
|
||||
let len = a.next().map_or(0, Vec::len);
|
||||
b.next();
|
||||
sources.extend(original.get(x..x.saturating_add(len)).unwrap_or_default());
|
||||
(x, y) = (x.saturating_add(len), y.saturating_add(len));
|
||||
(o, n) = (x, y);
|
||||
}
|
||||
Step::Delete => x = x.saturating_add(a.next().map_or(0, Vec::len)),
|
||||
Step::Insert => y = y.saturating_add(b.next().map_or(0, Vec::len)),
|
||||
}
|
||||
}
|
||||
replaced(&original, o, old.get(o..x).unwrap_or_default(), chars.get(n..y).unwrap_or_default(), &mut sources);
|
||||
}
|
||||
None => replaced(&original, 0, &old, &chars, &mut sources),
|
||||
}
|
||||
let fallback = original.first().copied().unwrap_or(0);
|
||||
let mut result: Vec<(std::ops::Range<usize>, usize)> = Vec::new();
|
||||
for (i, (byte, ch)) in new.iter().enumerate() {
|
||||
let source = if i < prefix {
|
||||
original.get(i).copied()
|
||||
} else if i >= new.len().saturating_sub(suffix) {
|
||||
original.get(old.len().saturating_sub(new.len() - i)).copied()
|
||||
} else {
|
||||
Some(inherited)
|
||||
}
|
||||
.unwrap_or(inherited);
|
||||
let source = sources.get(i).copied().unwrap_or(fallback);
|
||||
let end = byte.saturating_add(ch.len_utf8());
|
||||
if let Some((range, _)) = result.last_mut().filter(|(_, prev)| *prev == source) {
|
||||
range.end = end;
|
||||
@@ -311,6 +330,108 @@ impl TextBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Changed words [`TextBlock::edited_spans`] aligns before it treats the draft as one change.
|
||||
/// Myers' diff costs O((N + M) · D), so bounding D keeps it linear.
|
||||
const MAX_CHANGED_WORDS: usize = 64;
|
||||
|
||||
/// `text` split into words, each with the whitespace after it, as char ranges.
|
||||
fn words(text: &[char]) -> Vec<std::ops::Range<usize>> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
for (i, pair) in text.windows(2).enumerate() {
|
||||
if let [a, b] = pair
|
||||
&& a.is_whitespace()
|
||||
&& !b.is_whitespace()
|
||||
{
|
||||
out.push(start..i + 1);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if start < text.len() {
|
||||
out.push(start..text.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Step {
|
||||
Keep,
|
||||
Delete,
|
||||
Insert,
|
||||
}
|
||||
|
||||
/// Myers' shortest edit script from `a` to `b`, or `None` when it takes more than `max` steps.
|
||||
fn edit_script<T: PartialEq>(a: &[T], b: &[T], max: usize) -> Option<Vec<Step>> {
|
||||
let (n, m) = (a.len() as isize, b.len() as isize);
|
||||
let max = max.min(a.len().saturating_add(b.len())) as isize;
|
||||
// Furthest x reached on each diagonal k = x - y, stored at k + max + 1.
|
||||
let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0);
|
||||
let mut v = vec![0isize; (2 * max + 3) as usize];
|
||||
let mut trace = Vec::new();
|
||||
for d in 0..=max {
|
||||
trace.push(v.clone());
|
||||
for k in (-d..=d).step_by(2) {
|
||||
let down = k == -d || (k != d && at(&v, k - 1) < at(&v, k + 1));
|
||||
let mut x = if down { at(&v, k + 1) } else { at(&v, k - 1) + 1 };
|
||||
let mut y = x - k;
|
||||
while x < n && y < m && a.get(x as usize) == b.get(y as usize) {
|
||||
x += 1;
|
||||
y += 1;
|
||||
}
|
||||
if let Some(slot) = usize::try_from(k + max + 1).ok().and_then(|i| v.get_mut(i)) {
|
||||
*slot = x;
|
||||
}
|
||||
if x >= n && y >= m {
|
||||
return Some(backtrack(&trace, n, m, max));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Walk Myers' saved frontiers back from the end to the edit script.
|
||||
fn backtrack(trace: &[Vec<isize>], n: isize, m: isize, max: isize) -> Vec<Step> {
|
||||
let at = |v: &[isize], k: isize| usize::try_from(k + max + 1).ok().and_then(|i| v.get(i).copied()).unwrap_or(0);
|
||||
let mut steps = Vec::new();
|
||||
let (mut x, mut y) = (n, m);
|
||||
for (d, v) in trace.iter().enumerate().rev() {
|
||||
let (d, k) = (d as isize, x - y);
|
||||
let prev_k = if k == -d || (k != d && at(v, k - 1) < at(v, k + 1)) { k + 1 } else { k - 1 };
|
||||
let prev_x = at(v, prev_k);
|
||||
let prev_y = prev_x - prev_k;
|
||||
while x > prev_x && y > prev_y {
|
||||
steps.push(Step::Keep);
|
||||
x -= 1;
|
||||
y -= 1;
|
||||
}
|
||||
if d > 0 {
|
||||
steps.push(if x == prev_x { Step::Insert } else { Step::Delete });
|
||||
}
|
||||
(x, y) = (prev_x, prev_y);
|
||||
}
|
||||
steps.reverse();
|
||||
steps
|
||||
}
|
||||
|
||||
/// `old`, at char `at` of the source, became `new`: characters kept at either end keep their
|
||||
/// runs, and the rest inherit the run just before the change (or the first one it replaces).
|
||||
fn replaced(original: &[usize], at: usize, old: &[char], new: &[char], out: &mut Vec<usize>) {
|
||||
let prefix = old.iter().zip(new).take_while(|(a, b)| a == b).count();
|
||||
let suffix = old.iter().rev().zip(new.iter().rev()).take(old.len().min(new.len()).saturating_sub(prefix)).take_while(|(a, b)| a == b).count();
|
||||
let start = at.saturating_add(prefix);
|
||||
let inherited = start.checked_sub(1).and_then(|i| original.get(i)).or_else(|| original.get(start)).copied().unwrap_or(0);
|
||||
for i in 0..new.len() {
|
||||
let source = if i < prefix {
|
||||
original.get(at.saturating_add(i)).copied()
|
||||
} else if i >= new.len().saturating_sub(suffix) {
|
||||
original.get(at.saturating_add(old.len()).saturating_sub(new.len() - i)).copied()
|
||||
} else {
|
||||
Some(inherited)
|
||||
};
|
||||
out.push(source.unwrap_or(inherited));
|
||||
}
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
/// The baseline's height in user space.
|
||||
pub fn origin_baseline(&self) -> f64 {
|
||||
|
||||
Reference in New Issue
Block a user