diff --git a/ROADMAP.md b/ROADMAP.md index 8381d20a3..d855b8d5b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -384,6 +384,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-29** · Inserting text with many returns no longer takes time that grows with their square (part of #1588): each return copied its paragraph's format with its own insert, moving every format after it. Splitting 20,000 returns into paragraphs took 15.8 s; placing a text file of them now takes 0.2 s, and one of a million returns 5.5 s. No readiness percentage change. - **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change. - **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. - **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change. diff --git a/crates/doc/src/story.rs b/crates/doc/src/story.rs index 05adcb35f..bb56505f7 100644 --- a/crates/doc/src/story.rs +++ b/crates/doc/src/story.rs @@ -385,11 +385,11 @@ impl Story { // Paragraph formats: each new '\n' splits the current paragraph; the new paragraphs copy it. let pi = self.para_at(pos); let newlines = text.bytes().filter(|b| *b == b'\n').count(); - if newlines > 0 { - let f = self.paras[pi].clone(); - for _ in 0..newlines { - self.paras.insert(pi + 1, f.clone()); - } + if newlines > 0 + && let Some(f) = self.paras.get(pi).cloned() + { + // All at once: an insert per return made many returns quadratic (#1588). + self.paras.splice(pi + 1..pi + 1, std::iter::repeat_n(f, newlines)); } self.text.insert_str(pos, text); // Character runs. @@ -669,6 +669,19 @@ mod tests { assert_eq!(s.para_ranges(), vec![0..6]); } + /// Each return inserted its paragraph format on its own, quadratic in the returns (#1588). + #[test] + fn many_returns_split_a_paragraph_at_once() { + let para = ParaFormat { style: "Body".into(), ..ParaFormat::default() }; + let started = std::time::Instant::now(); + let s = Story::with_text(StoryId(1), &"\n".repeat(20_000), para.clone()); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(1), "took {took:?}"); + assert_eq!(s.paras.len(), 20_001); + assert!(s.paras.iter().all(|p| *p == para)); + s.check().unwrap(); + } + #[test] fn char_formats_split_and_merge() { let mut s = st("hello world");