mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-09 20:00:11 -06:00
* 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>
323 lines
13 KiB
Rust
323 lines
13 KiB
Rust
use super::reader::Reader;
|
|
use super::state::{Dest, HeaderFooter, HfPages, MAX_PICT, PageGeom};
|
|
use super::{Cell, Document, FBlock, Flow, Para, model};
|
|
use std::sync::Arc;
|
|
|
|
/// Parse RTF bytes into the flow model.
|
|
pub fn parse(bytes: &[u8]) -> Result<Flow, String> {
|
|
parse_with_page(bytes).map(|(f, _, _)| f)
|
|
}
|
|
|
|
/// The flow, the page, and the default font (`\deff`, font 0 when the file names none), which
|
|
/// is the font of all text that doesn't select one.
|
|
fn parse_with_page(bytes: &[u8]) -> Result<(Flow, PageGeom, Option<String>), String> {
|
|
parse_full(bytes).map(|r| (r.flow, r.page, r.default_font))
|
|
}
|
|
|
|
/// Everything an import takes from an RTF file.
|
|
struct Parsed {
|
|
flow: Flow,
|
|
page: PageGeom,
|
|
/// The last section's properties (the others are on the paragraphs that end them).
|
|
last_section: wordcraft_doc::SectionProps,
|
|
default_font: Option<String>,
|
|
/// Each section's headers and footers, in order, the last section's last.
|
|
headers_footers: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
|
|
}
|
|
|
|
fn parse_full(bytes: &[u8]) -> Result<Parsed, String> {
|
|
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
|
if !bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf")) {
|
|
return Err("not an RTF document (it doesn't start with {\\rtf)".into());
|
|
}
|
|
let mut r = Reader::default();
|
|
let mut i = start;
|
|
let mut first_in_group = false;
|
|
let mut starred = false;
|
|
while let Some(&c) = bytes.get(i) {
|
|
match c {
|
|
b'{' => {
|
|
r.open();
|
|
first_in_group = true;
|
|
starred = false;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
b'}' => {
|
|
r.close();
|
|
if r.info_depth.is_some_and(|d| r.depth() < d) {
|
|
r.info_depth = None;
|
|
}
|
|
first_in_group = false;
|
|
starred = false;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
b'\\' => {
|
|
let Some(&n) = bytes.get(i + 1) else { break };
|
|
if n.is_ascii_alphabetic() {
|
|
let mut j = i + 1;
|
|
while bytes.get(j).is_some_and(u8::is_ascii_alphabetic) && j - i <= 32 {
|
|
j += 1;
|
|
}
|
|
let word = std::str::from_utf8(bytes.get(i + 1..j).unwrap_or(b"")).unwrap_or("");
|
|
let mut k = j;
|
|
let neg = bytes.get(k) == Some(&b'-');
|
|
if neg {
|
|
k += 1;
|
|
}
|
|
let d0 = k;
|
|
while bytes.get(k).is_some_and(u8::is_ascii_digit) && k - d0 < 10 {
|
|
k += 1;
|
|
}
|
|
let param = if k > d0 {
|
|
let v = std::str::from_utf8(bytes.get(d0..k).unwrap_or(b"0")).ok().and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
|
|
let v = if neg { -v } else { v };
|
|
// A date (DTTM) is a 32-bit pattern some writers put unsigned.
|
|
let v = if matches!(word, "revdttm" | "revdttmdel" | "crdate" | "prdate")
|
|
&& v > i64::from(i32::MAX)
|
|
&& v <= i64::from(u32::MAX)
|
|
{
|
|
v - (1i64 << 32)
|
|
} else {
|
|
v
|
|
};
|
|
Some(v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32)
|
|
} else {
|
|
if neg {
|
|
k -= 1;
|
|
}
|
|
None
|
|
};
|
|
if bytes.get(k) == Some(&b' ') {
|
|
k += 1;
|
|
}
|
|
i = k;
|
|
if word == "bin" {
|
|
let n = param.unwrap_or(0).max(0) as usize;
|
|
let end = i.saturating_add(n).min(bytes.len());
|
|
if r.st.dest == Dest::Pict
|
|
&& let Some(data) = bytes.get(i..end)
|
|
&& r.pict_hex.len() + data.len() <= MAX_PICT
|
|
{
|
|
r.pict_hex.extend_from_slice(data);
|
|
}
|
|
i = end;
|
|
continue;
|
|
}
|
|
if r.skip_chars > 0
|
|
&& 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());
|
|
match h {
|
|
Some(v) => {
|
|
if r.st.dest == Dest::Pict {
|
|
// Hex digits, two at a time, without a String per picture byte.
|
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
|
let h = [HEX[usize::from(v >> 4)], HEX[usize::from(v & 0x0f)]];
|
|
if let Ok(s) = std::str::from_utf8(&h) {
|
|
r.text(s);
|
|
}
|
|
} else {
|
|
r.byte(v);
|
|
}
|
|
i += 4;
|
|
}
|
|
None => i += 2,
|
|
}
|
|
}
|
|
b'*' => {
|
|
starred = true;
|
|
i += 2;
|
|
continue;
|
|
}
|
|
b'~' | b' ' => {
|
|
r.ch('\u{00A0}');
|
|
i += 2;
|
|
}
|
|
b'-' => {
|
|
r.ch('\u{00AD}');
|
|
i += 2;
|
|
}
|
|
b'_' => {
|
|
r.ch('\u{2011}');
|
|
i += 2;
|
|
}
|
|
b'\n' | b'\r' => {
|
|
r.word("par", None, false, false);
|
|
i += 2;
|
|
}
|
|
b'\\' | b'{' | b'}' => {
|
|
r.ch(n as char);
|
|
i += 2;
|
|
}
|
|
_ => i += 2,
|
|
}
|
|
first_in_group = false;
|
|
}
|
|
b'\r' | b'\n' | 0 => i += 1,
|
|
_ => {
|
|
// A run of plain text.
|
|
let mut j = i;
|
|
while bytes.get(j).is_some_and(|b| !matches!(b, b'{' | b'}' | b'\\' | b'\r' | b'\n' | 0)) {
|
|
j += 1;
|
|
}
|
|
let chunk = bytes.get(i..j).unwrap_or(b"");
|
|
if r.st.dest == Dest::Pict && r.skip_chars == 0 && chunk.is_ascii() {
|
|
// Picture hex is not text: it needs no decoder, so neither a pending lead byte
|
|
// nor the code page's single-byte path applies to it.
|
|
if let Ok(s) = std::str::from_utf8(chunk) {
|
|
r.text(s);
|
|
}
|
|
} else if r.skip_chars == 0 && chunk.is_ascii() && r.mb_decoder.is_none() {
|
|
// Plain ASCII with no multibyte run open: append it as it is.
|
|
if let Ok(s) = std::str::from_utf8(chunk) {
|
|
r.text(s);
|
|
}
|
|
} else {
|
|
r.run(chunk);
|
|
}
|
|
i = j;
|
|
first_in_group = false;
|
|
}
|
|
}
|
|
}
|
|
Ok(finalize(r))
|
|
}
|
|
|
|
fn finalize(mut r: Reader) -> Parsed {
|
|
// A header or footer left open by a truncated file ends here, and so does a multibyte run.
|
|
r.finish_multibyte();
|
|
r.end_header_footer();
|
|
if r.para_has || !r.para.inlines.is_empty() {
|
|
r.end_para(false);
|
|
}
|
|
r.close_table();
|
|
if let Some((y, mo, d, h, mi)) = r.created
|
|
&& y > 0
|
|
{
|
|
r.meta.created = format!("{y:04}-{:02}-{:02}T{:02}:{:02}:00Z", mo.clamp(1, 12), d.clamp(1, 31), h.min(23), mi.min(59));
|
|
}
|
|
let mut blocks = std::mem::take(&mut r.body);
|
|
// A file that ends with `\sect` ends its last section there: no empty one follows, and
|
|
// header or footer groups after that `\sect` (a section without text) are dropped.
|
|
let mut last_section = r.sec.props(&r.page);
|
|
let mut headers_footers = std::mem::take(&mut r.section_parts);
|
|
if let Some(FBlock::Para(p)) = blocks.last_mut()
|
|
&& let Some(s) = p.section.take()
|
|
{
|
|
last_section = *s;
|
|
} else {
|
|
headers_footers.push(std::mem::take(&mut r.hf_parts));
|
|
}
|
|
if blocks.is_empty() {
|
|
blocks.push(FBlock::Para(Para::default()));
|
|
}
|
|
let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string());
|
|
Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers }
|
|
}
|
|
|
|
/// The column grid of a table from its rows' cell right edges: every distinct edge starts a grid
|
|
/// column, and each cell spans the grid columns between its left and right edges, as Word lays
|
|
/// out RTF rows whose cells don't line up. Returns the widths in points, or none (equal columns)
|
|
/// when the edges don't make a grid.
|
|
pub(super) fn column_grid(rows: &mut [Vec<Cell>], rights: &[Vec<i32>]) -> Vec<f32> {
|
|
let mut edges: Vec<i32> = rights.iter().flatten().copied().filter(|&x| x > 0).collect();
|
|
edges.sort_unstable();
|
|
edges.dedup();
|
|
let ok = rights.len() == rows.len()
|
|
&& rows
|
|
.iter()
|
|
.zip(rights)
|
|
.all(|(r, x)| r.len() == x.len() && x.windows(2).all(|w| w.first() < w.get(1)) && x.first().is_some_and(|&v| v > 0))
|
|
&& !edges.is_empty()
|
|
&& edges.len() <= 63;
|
|
if !ok {
|
|
return Vec::new();
|
|
}
|
|
for (row, x) in rows.iter_mut().zip(rights) {
|
|
let mut left = 0;
|
|
for (c, &right) in row.iter_mut().zip(x) {
|
|
let span = edges.iter().filter(|&&e| e > left && e <= right).count().max(1);
|
|
c.colspan = span as u32;
|
|
left = right;
|
|
}
|
|
}
|
|
let mut prev = 0;
|
|
edges
|
|
.iter()
|
|
.map(|&e| {
|
|
let w = (e - prev) as f32 / 20.0;
|
|
prev = e;
|
|
w
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Parse RTF bytes into a document.
|
|
pub fn import(bytes: &[u8]) -> Result<Document, String> {
|
|
model::begin_import();
|
|
let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?;
|
|
let mut doc = model::to_doc_in(&flow, last_section);
|
|
// Each section's own headers and footers; those it leaves out stay linked to the previous.
|
|
let mut sets = Vec::with_capacity(headers_footers.len());
|
|
for parts in &headers_footers {
|
|
let (mut headers, mut footers) = (wordcraft_doc::section::HeaderSet::default(), wordcraft_doc::section::HeaderSet::default());
|
|
for (which, blocks) in parts {
|
|
let blocks = model::part_blocks(&mut doc, blocks);
|
|
let id = doc.add_part(if which.footer { wordcraft_doc::PartKind::Footer } else { wordcraft_doc::PartKind::Header }, blocks);
|
|
let set = if which.footer { &mut footers } else { &mut headers };
|
|
match which.pages {
|
|
HfPages::Default => set.default = Some(id),
|
|
HfPages::First => set.first = Some(id),
|
|
HfPages::Even => set.even = Some(id),
|
|
}
|
|
}
|
|
sets.push((headers, footers));
|
|
}
|
|
let mut sets = sets.into_iter();
|
|
for b in &mut doc.body {
|
|
if let wordcraft_doc::Block::Para(p) = &**b
|
|
&& p.section.is_some()
|
|
&& let wordcraft_doc::Block::Para(p) = std::sync::Arc::make_mut(b)
|
|
&& let Some(s) = p.section.as_deref_mut()
|
|
&& let Some((h, f)) = sets.next()
|
|
{
|
|
(s.headers, s.footers) = (h, f);
|
|
}
|
|
}
|
|
if let Some((h, f)) = sets.next() {
|
|
(doc.last_section.headers, doc.last_section.footers) = (h, f);
|
|
}
|
|
// RTF's paragraph defaults are single spacing and no space around paragraphs; what a file
|
|
// wants beyond that it states on each paragraph.
|
|
let dp = &mut Arc::make_mut(&mut doc.styles).default_para;
|
|
dp.space_before = Some(0.0);
|
|
dp.space_after = Some(0.0);
|
|
dp.line_spacing = Some(wordcraft_doc::props::LineSpacing::Multiple(1.0));
|
|
if default_font.is_some() {
|
|
Arc::make_mut(&mut doc.styles).default_chr.font = default_font;
|
|
}
|
|
Ok(doc)
|
|
}
|