RTF: honor Unicode fallback counts above eight (#5607)

* RTF: honor the declared Unicode fallback count

* RTF: record declared Unicode fallback counts

* M12: count unknown RTF symbols in Unicode fallback

Address the unrecognized-control-symbol review finding in parse.rs. Each symbol consumes one Unicode fallback unit; outside fallback it remains ignored. Four additional actual-import cases cover uc0, uc1, uc2 and uc18. The uc2 regression fails before the fix (≤ rest versus ≤b rest). All 664 formats tests, scoped rustfmt and strict all-target formats Clippy pass in the exact-source isolated closure. Binary fallback remains separate in #4806.

* M12: count structural RTF words in Unicode fallback

Treat par, pard, cell and row like other control words while consuming declared Unicode fallback. Add actual import cases for those fallback units and a uc0 paragraph control. Scoped rustfmt passes; full validation is pending hosted CI.

* M12: describe RTF fallback units precisely

Address the review wording finding: the Unicode fallback count measures units, including control words and escaped symbols, rather than fixed-length bytes. Preserve every roadmap history and progress record.

* M12: count all RTF fallback control symbols

Consume non-hex control symbols before dispatch while Unicode fallback remains, covering escaped LF/CR and star without applying their side effects. Keep valid hex escapes on their four-byte path, and include revision-author text in the existing control-word guard. Add six actual body-import cases and two actual revision-author cases, including uc0 controls. Both regressions fail before the patch in the exact current closure; all 665 formats tests, scoped rustfmt and strict all-target formats Clippy pass afterward. Binary and invalid-hex handling remain separate. Full hosted gate will be checked on the draft PR.

* M12: preserve the roadmap final newline

Restore the trailing LF omitted by copying the Raw view. The roadmap now differs only by the requested fallback-unit wording; production code and tests are unchanged.

* M12: skip RTF info effects during Unicode fallback

Move the existing info_depth assignment after the existing fallback-control-word guard. A skipped info word inside a Unicode title fallback must not overwrite metadata depth and drop the following author. Preserve the binary and escaped-hex paths, other destination handling and uc0 effects. The supplied metadata import regresses before the move; all 667 formats tests, scoped rustfmt and strict all-target formats Clippy pass afterward. Regression coverage follows in this draft PR.

* M12: cover RTF fallback metadata depth

Add four actual RTF import cases asserting title, creator and body text: skipped info controls at uc9 and uc1 retain the following author; uc0 title and a real info group after body Unicode still work. The supplied uc9 case fails before the parser reorder with an empty creator instead of Alice. All 667 formats tests, scoped rustfmt and strict all-target formats Clippy pass in the exact c153 source closure plus this two-file follow-up. Full hosted CI will be checked on the draft PR.

---------

Co-authored-by: Martin Ball <martin.ball@ucscaregroup.co.uk>
This commit is contained in:
MexandMartin Ball authored and GitHub committed 2026-11-08 02:10:58 +01:00
1 parent 7855a2d814
commit e53cae3482
4 files changed
+69 -6

No files matched your search

+2
View File
@@ -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) |
+10 -5
View File
@@ -105,22 +105,27 @@ fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
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());
+56
View File
@@ -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.
+1 -1
View File
@@ -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();