diff --git a/ROADMAP.md b/ROADMAP.md index ff35056105..d7852dd23a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -157,6 +157,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-11-04: MHT import keeps semicolons and escaped quotes inside quoted MIME parameters, so an unrelated parameter cannot supply a false multipart boundary and hide the document text. Readiness estimates unchanged. - 2026-10-30: Equations: `\int`, `\sum` and the other n-ary operators typed with AutoCorrect wait for their limits. `\int` + Space, then `_0^1` and Space, gives ∫ with its limits and the caret in its operand, and `\int_0^1` typed in one go does the same, since any character that can't continue `\name` corrects it; this is observed in Word for Windows on #4938. Enter or an arrow key builds it without going into the operand, and `∫∫` puts the operand in the inner operator; those two are not observed in Word (#4917). Readiness unchanged. +- 2026-11-04: RTF import skips the declared Unicode fallback count even when it exceeds eight fallback units, so longer ANSI alternatives do not leak into the document text. Skipping stays bounded by the available input and ends at a group boundary. Readiness estimates unchanged. - 2026-10-28: Ink replay, eraser picking, lasso selection and arrow-candidate discovery reuse the paragraph prefixes they have already scanned (#3614). Page item order, stroke geometry and saved data retain their behavior. This narrows the stability work; ink import fidelity, native pen input and broader corpus qualification remain open. - 2026-10-29: Page drawing finds text-box warps without rescanning the paragraph for every box (#3614). Only requested offsets are indexed, and each paragraph walk stops after its last requested character. Original Linux discovery, complete display-output and CLI/MCP save/reopen controls; native Word comparison untested. Readiness estimates unchanged. - 2026-10-28: Protected typing boundary lookup counts preceding objects once and indexes editable permission IDs (#3614); caret movement and enforcement policy are unchanged. Existing mutation and protection checks retain their costs; this does not close the broader performance gap. @@ -829,6 +830,7 @@ MATH glyphs that reference the same construction now share its immutable variant | 2026-11-04 | trivial | Progress log: MHT quoted MIME parameters; readiness estimates unchanged | | 2026-10-30 | trivial | Progress log: typed n-ary operators such as ∫ and ∑ wait for their limits, and any character that cannot continue a math name corrects it, as observed in Word for Windows on #4938; Enter, arrow keys and nested operators are WordCraft's own choices (#4917); readiness unchanged | | 2026-11-05 | trivial | DOCX paragraph `w:adjustRightInd` is preserved with true, false and absent values; OOXML properties follow schema order. Grid layout remains open. | +| 2026-11-04 | trivial | Progress log: RTF Unicode fallback counts; readiness estimates unchanged | | 2026-10-28 | trivial | Per-page ink object lookups, #3614. Drawing order, geometry and saved data retain their behavior; no readiness estimate change | | 2026-11-05 | trivial | Progress log: ODT heading text of another size (gap 19) | | 2026-11-05 | trivial | Progress log: ODT table rows that don't break (gap 19) | diff --git a/crates/formats/src/rtf/parse.rs b/crates/formats/src/rtf/parse.rs index d92545bd49..c8249c75ec 100644 --- a/crates/formats/src/rtf/parse.rs +++ b/crates/formats/src/rtf/parse.rs @@ -105,22 +105,27 @@ fn parse_full(bytes: &[u8]) -> Result { i = end; continue; } - if word == "info" { - r.info_depth = Some(r.depth()); - } if r.skip_chars > 0 - && matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl) - && !matches!(word, "par" | "pard" | "cell" | "row") + && matches!(r.st.dest, Dest::Body | Dest::Info(_) | Dest::FldInst | Dest::Bookmark | Dest::FontTbl | Dest::RevTbl) { r.skip_chars -= 1; first_in_group = false; continue; } + if word == "info" { + r.info_depth = Some(r.depth()); + } r.word(word, param, starred, first_in_group); first_in_group = false; starred = false; continue; } + if r.skip_chars > 0 && n != b'\'' { + r.skip_chars -= 1; + i += 2; + first_in_group = false; + continue; + } match n { b'\'' => { let h = bytes.get(i + 2..i + 4).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()); diff --git a/crates/formats/src/rtf/tests.rs b/crates/formats/src/rtf/tests.rs index 2add84f341..d55ba62252 100644 --- a/crates/formats/src/rtf/tests.rs +++ b/crates/formats/src/rtf/tests.rs @@ -37,6 +37,62 @@ fn basic_reader() { assert!(matches!(&l.inlines[0], Inline::Text(_, f) if f.link.as_deref() == Some("http://a.b"))); } +#[test] +fn unicode_fallback_uses_the_declared_count() { + for (rtf, expected) in [ + (br"{\rtf1\uc18\u8804less than or equal rest}".as_slice(), "≤ rest"), + (br"{\rtf1\uc10\u233\'30\'31\'32\'33\'34\'35\'36\'37\'38\'39 rest}", "é rest"), + (br"{\rtf1\uc2\u8804\:ab rest}", "≤b rest"), + (br"{\rtf1\uc18\u8804\:12345678901234567 rest}", "≤ rest"), + (br"{\rtf1\uc1\u8804\:ab rest}", "≤ab rest"), + (br"{\rtf1\uc0\u8804\:ab rest}", "≤ab rest"), + (br"{\rtf1\uc9\u233abcdefgh\par VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc1\u233\pard VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc1\u233\cell VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc1\u233\row VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc0\u233\par VISIBLE}", "é\nVISIBLE"), + (b"{\\rtf1\\uc9\\u233abcdefgh\\\nVISIBLE}", "éVISIBLE"), + (b"{\\rtf1\\uc9\\u233abcdefgh\\\rVISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc9\u233abcdefgh\*VISIBLE}", "éVISIBLE"), + (b"{\\rtf1\\uc0\\u233\\\nVISIBLE}", "é\nVISIBLE"), + (b"{\\rtf1\\uc0\\u233\\\rVISIBLE}", "é\nVISIBLE"), + (br"{\rtf1\uc0\u233\*VISIBLE}", "éVISIBLE"), + (br"{\rtf1\uc0\u233X}", "éX"), + (br"{\rtf1\uc1\u233?X}", "éX"), + (br"{\rtf1{\uc2147483647\u233?}X}", "éX"), + (br"{\rtf1\uc-1\u233X}", "éX"), + ] { + let doc = import(rtf).unwrap(); + assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), expected, "{rtf:?}"); + } +} + +#[test] +fn unicode_fallback_info_words_preserve_following_metadata() { + for (rtf, title, body) in [ + (br"{\rtf1{\info{\title\uc9\u233abcdefgh\info X}{\author Alice}} Text}".as_slice(), "éX", "Text"), + (br"{\rtf1{\info{\title\uc1\u233\info X}{\author Alice}} Text}", "éX", "Text"), + (br"{\rtf1{\info{\title\uc0\u233X}{\author Alice}} Text}", "éX", "Text"), + (br"{\rtf1\uc0\u233{\info{\title Plain}{\author Alice}}Text}", "Plain", "éText"), + ] { + let doc = import(rtf).unwrap(); + assert_eq!(doc.core.title, title, "{rtf:?}"); + assert_eq!(doc.core.creator, "Alice", "{rtf:?}"); + assert_eq!(doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), body, "{rtf:?}"); + } +} + +#[test] +fn unicode_fallback_in_revision_authors_counts_control_words() { + for (rtf, expected) in [ + (br"{\rtf1{\*\revtbl{Unknown;}{\uc9\u233abcdefgh\par Zed;}}{\revised\revauth1 text}}".as_slice(), "éZed"), + (br"{\rtf1{\*\revtbl{Unknown;}{\uc0\u233\par Zed;}}{\revised\revauth1 text}}", "éZed"), + ] { + let doc = import(rtf).unwrap(); + assert_eq!(doc.revisions.first().map(|r| r.author.as_str()), Some(expected), "{rtf:?}"); + } +} + #[test] fn paragraph_borders() { // A heading with a grey rule under it, as hh.ru résumés write their section titles. diff --git a/crates/formats/src/rtf/word.rs b/crates/formats/src/rtf/word.rs index b3cb909f9b..87a033caab 100644 --- a/crates/formats/src/rtf/word.rs +++ b/crates/formats/src/rtf/word.rs @@ -89,7 +89,7 @@ impl Reader { } self.skip_chars = self.st.uc; } - "uc" => self.st.uc = pv.clamp(0, 8) as usize, + "uc" => self.st.uc = pv.max(0) as usize, "plain" => { self.st.fmt = Fmt { link: self.st.fmt.link.clone(), ..Default::default() }; self.st.rev = RevSt::default();