craft-rtf: WordCraft's RTF reader and writer on craft-wordmodel and craft-flow (feature doc)

craft-rtf: WordCraft's RTF reader and writer on craft-wordmodel and craft-flow (feature doc)

* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.

* Add craft-flow: the flow model the text formats map to, on craft-wordmodel

WordCraft's crates/formats/src/model (07f97737) moved as it is: Flow,
FBlock, Para, Inline, Fmt, tables and cells, pictures, to_doc and
from_doc, picture sniffing, colours, base64 and the Dropped report. Only
the paths changed (wordcraft_doc is craft_wordmodel); the report helpers
are public so the format crates can call them; the image re-encoding
helper stays with WordCraft's RTF writer.

* wip: WordCraft's RTF interpreter, as it is

* Take main's side of files outside craft-rtf

* craft-rtf: WordCraft's RTF reader and writer as they are at wordcraft 07f97737

crates/formats/src/rtf.rs and crates/formats/src/rtf/, unchanged and not yet
compiled (no module declares them), so the next commit shows only what the
move changes.

* craft-rtf: WordCraft's RTF reader and writer as craft_rtf::doc (feature doc)

On craft-flow and craft-wordmodel, with this crate's code pages
(craft-codepage), dates and escaping in place of WordCraft's copies, and
craft-metafile's metafile sniffing. A picture in a format RTF cannot carry is
re-encoded as PNG with a bounded decode (100 megapixels, 512 MiB), as
WordCraft's picture decoder bounded it.

The 76 tests moved with it.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 22:44:19 -05:00
1 parent c2b7dac5a2
commit 7fe902292f
12 files changed
+5408 -4

No files matched your search

+8
View File
@@ -14,6 +14,8 @@ workspace = true
# `outlook`: compressed RTF ([MS-OXRTFCP]) and the text or encapsulated HTML ([MS-OXRTFEX]) of a
# mail item's body.
outlook = ["dep:craft-mime"]
# `doc`: RTF read into and written from the word model (craft-wordmodel), through craft-flow.
doc = ["dep:craft-flow", "dep:craft-metafile", "dep:craft-wordmodel", "dep:image"]
[dependencies]
craft-codepage = { workspace = true }
@@ -22,5 +24,11 @@ craft-mime = { workspace = true, optional = true }
# The East Asian multibyte code pages (932, 936, 949, 950).
encoding_rs = { workspace = true }
# The word-model reader and writer (`doc`).
craft-flow = { workspace = true, optional = true }
craft-metafile = { workspace = true, optional = true }
craft-wordmodel = { workspace = true, optional = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "bmp", "tiff", "webp"], optional = true }
[dev-dependencies]
proptest = { workspace = true }
+17 -4
View File
@@ -90,9 +90,22 @@ at most `MAX_RTF_DEPTH` (256) deep. From MailCraft `crates/pst/src/rtf.rs` at
[a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5),
by Martin Ball and the MailCraft contributors.
## The word model (`doc` feature)
`craft_rtf::doc` reads RTF into the word model (`craft_wordmodel::Document`) and writes it
back, through the text formats' flow (`craft_flow`): styles, lists, fields, bookmarks,
pictures (PNG, JPEG, metafiles; other formats re-encoded as PNG within a bounded decode),
tables, notes, comments, tracked changes, sections, headers and footers. It is WordCraft's RTF
reader and writer, on this crate's code pages, dates and escaping.
- `doc::import(bytes) -> Result<Document, String>`, `doc::parse(bytes) -> Result<Flow, String>`
- `doc::export(&Document) -> String`
Limits: group nesting (512), picture data (64 MiB a picture), notes (5,000; 500 blocks each),
sections (10,000), revision authors (10,000), fonts (4,096).
## Not here yet
WordCraft's full reader and writer (styles, lists, fields, bookmarks, pictures, notes,
comments, tracked changes, sections, headers and footers) are written against WordCraft's own
document model and stay there until that model has a neutral form. They can already sit on
`Tokens`, `TextDecoder`, `esc` and the date functions.
`doc` keeps its own tokenizing loop, which has rules the shared `Tokens` does not (`\bin` data
and fallback characters inside picture data, the destinations a `\uN` fallback is skipped in).
Moving it onto `Tokens` is a follow-up.
+60
View File
@@ -0,0 +1,60 @@
//! Rich Text Format, read into and written from the word model (`craft_wordmodel::Document`)
//! through the text formats' flow (`craft_flow`). Feature `doc`.
//!
//! WordCraft's RTF reader and writer.
//!
//! Export: font and colour tables, a small stylesheet (headings with outline levels, Title,
//! Quote, Code), list tables (`\listtable` / `\listoverridetable` with `\listtext` labels for
//! older readers), document info, page setup, paragraph formatting (`\pard`, alignment, page
//! break before), character formatting (`\b \i \ul \strike \super \sub \f \fs \cf \chcbpat`),
//! hyperlinks as `HYPERLINK` fields, bookmarks, pictures (`\pngblip` / `\jpegblip`), tables
//! (`\trowd … \cellx … \cell … \row`, header rows, vertical merges, shading), footnotes and
//! endnotes (`{\super\chftn}{\footnote[\ftnalt] …}`) and comments (`{\*\atrfstart n}` …
//! `{\*\atrfend n}` around the commented text, then `{\*\atnid initials}{\*\atnauthor name}`
//! `\chatn{\*\annotation{\*\atnref n}{\*\atndate …}` text `}`).
//!
//! Import: a tolerant reader for groups and destinations (unknown `\*` destinations and objects
//! are skipped; headers, footers, notes and comments are read into stories of their own),
//! `\par \line \tab \page`, `\uN` with `\ucN`
//! fallback skipping, `\'hh` and 8-bit text in the document's code page (`\ansicpg`, `\mac`, `\pc`, `\pca`) or
//! the current font's (`\fcharset`, `\cpg`: Cyrillic, Central European, Greek, Mac OS Roman, the
//! East Asian multibyte pages 932/936/949/950…; Windows-1252 when none is named). A multibyte
//! code page's bytes stream through one decoder, so a lead byte and its trail byte decode
//! together and a run that looks like a UTF-8 BOM is not mistaken for one; the formatting words
//! above, stylesheet-based
//! headings and `\outlinelevel`, lists through `\ls`/`\ilvl`, fields, bookmarks, pictures and
//! tables, footnotes, endnotes (`\footnote` with `\ftnalt`) and comments (author, initials, date,
//! text, the commented range). Group nesting, picture sizes and the number of notes and comments
//! are capped. Not kept: custom note marks, comment replies (`\atnparent`), notes inside
//! headers, footers and other notes, tables inside note and comment texts (flattened).
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use craft_wordmodel::props::VAlign;
use craft_wordmodel::{Align, Block, Blocks, Document, InlineObject, Paragraph, Rgb};
use craft_flow::{self as model, Cell, FBlock, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img};
use crate::date;
mod export;
mod parse;
mod reader;
mod state;
mod styles;
mod text;
mod word;
pub use date::{dttm_to_iso, iso_to_dttm};
#[cfg(test)]
use export::{Tables, Writer};
pub use export::{esc, export};
pub use parse::{import, parse};
use reader::Reader;
#[cfg(test)]
use state::MAX_SECTIONS;
#[cfg(test)]
use word::hyperlink;
#[cfg(test)]
mod tests;
+972
View File
@@ -0,0 +1,972 @@
use super::state::{MARK_CLOSE, MARK_OPEN, MAX_NOTE_BLOCKS};
use super::styles::{style_fmt, styled};
use super::{
Align, Arc, Block, Blocks, Document, FBlock, FTable, Fmt, HashSet, Inline, InlineObject, Kind, Meta, Para, Paragraph, RevMark, Rgb, VAlign, model,
};
use crate::date::{date_words as rtf_date, iso_to_dttm};
// ---------------------------------------------------------------------------------------------
// Export
pub use crate::{esc, esc_font};
pub(super) struct Tables {
pub(super) fonts: Vec<String>,
pub(super) colors: Vec<Rgb>,
}
impl Tables {
pub(super) fn font(&mut self, name: &str) -> usize {
match self.fonts.iter().position(|f| f.eq_ignore_ascii_case(name)) {
Some(i) => i,
None => {
self.fonts.push(name.to_string());
self.fonts.len() - 1
}
}
}
/// Colour table index (1-based; 0 is "auto").
fn color(&mut self, c: Rgb) -> usize {
match self.colors.iter().position(|x| *x == c) {
Some(i) => i + 1,
None => {
self.colors.push(c);
self.colors.len()
}
}
}
}
/// What a place marker in the exported text stands for (see [`MARK_OPEN`]).
pub(super) enum Mark {
Note {
endnote: bool,
blocks: Vec<FBlock>,
},
RangeStart(u32),
/// The end of a comment's range; the first end of each comment also carries its annotation.
RangeEnd {
id: u32,
ann: Option<Annotation>,
},
Done,
}
pub(super) struct Annotation {
author: String,
initials: String,
date: String,
blocks: Vec<FBlock>,
}
pub(super) struct Writer {
pub(super) marks: Vec<Mark>,
pub(super) t: Tables,
/// List definitions: per list, the ordered flag per level.
pub(super) lists: Vec<[bool; 9]>,
/// Authors of tracked changes (`\revtbl` entries after "Unknown"), when the text has any.
pub(super) authors: Option<Vec<String>>,
/// The character formatting the current paragraph's style gives its runs (heading bold and
/// size, code font…): what a run's formatting without a direct value resolves to.
pub(super) base: Fmt,
/// The body's sections in order (the last is the document's); empty when it has just one.
pub(super) sections: Vec<craft_wordmodel::SectionProps>,
/// The section being written.
pub(super) sect_i: usize,
}
pub(super) fn has_marks(bl: &Blocks, depth: usize) -> bool {
bl.iter().any(|b| match &**b {
Block::Para(p) => {
p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { .. } | InlineObject::CommentStart { .. } | InlineObject::CommentEnd { .. }))
}
Block::Table(t) => depth < 8 && t.rows.iter().any(|r| r.cells.iter().any(|c| has_marks(&c.blocks, depth + 1))),
})
}
/// A copy of `doc` whose note references and comment range ends are place markers in the text
/// (see [`MARK_OPEN`]), with what each stands for; none when the body has neither.
fn mark_doc(doc: &Document) -> Option<(Document, Vec<Mark>)> {
if !has_marks(&doc.body, 0) {
return None;
}
let mut m = Marker { doc, marks: Vec::new(), annotated: HashSet::new() };
let body = m.blocks(&doc.body, None, 0);
let mut d = doc.clone();
d.body = body;
Some((d, m.marks))
}
/// The story id standing for "inside a note or comment": no nested notes, no comment ranges.
const IN_STORY: u32 = u32::MAX;
struct Marker<'a> {
doc: &'a Document,
marks: Vec<Mark>,
annotated: HashSet<u32>,
}
impl Marker<'_> {
fn token(&mut self, m: Mark) -> String {
self.marks.push(m);
self.marks.len().saturating_sub(1).to_string()
}
/// The flow of a story (a note's or comment's text); tables are flattened to their paragraphs.
fn story(&mut self, bl: &Blocks, note: u32, depth: usize, out: &mut Vec<FBlock>) {
for b in bl.iter().take(MAX_NOTE_BLOCKS) {
match &**b {
Block::Para(p) => {
let q = self.para(p, Some(note));
out.extend(model::flow_paras(self.doc, &q).into_iter().map(FBlock::Para));
}
Block::Table(t) if depth < 4 => {
for c in t.rows.iter().flat_map(|r| r.cells.iter()) {
self.story(&c.blocks, note, depth + 1, out);
}
}
Block::Table(_) => {}
}
}
}
fn blocks(&mut self, bl: &Blocks, note: Option<u32>, depth: usize) -> Blocks {
bl.iter()
.map(|b| match &**b {
Block::Para(p) if p.objects.is_empty() => b.clone(),
Block::Para(p) => Arc::new(Block::Para(self.para(p, note))),
Block::Table(t) if depth < 8 => {
let mut t = t.clone();
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
c.blocks = self.blocks(&c.blocks, note, depth + 1);
}
Arc::new(Block::Table(t))
}
Block::Table(_) => b.clone(),
})
.collect()
}
/// `p` with its note references and comment range ends replaced by place markers. `note` is
/// the note being written, when `p` is part of one.
fn para(&mut self, p: &Paragraph, note: Option<u32>) -> Paragraph {
let mut q = p.clone();
for off in p.object_offsets().into_iter().rev() {
let Some(obj) = q.object_at(off).cloned() else { continue };
let tok = match obj {
InlineObject::NoteRef { id, .. } if note == Some(id) => Some(String::from("S")),
InlineObject::NoteRef { .. } if note.is_some() => Some(String::new()),
InlineObject::NoteRef { id, kind, .. } => self.doc.parts.get(&id).map(|part| {
let mut blocks = Vec::new();
self.story(&part.blocks, id, 0, &mut blocks);
self.token(Mark::Note { endnote: kind == craft_wordmodel::para::NoteKind::Endnote, blocks })
}),
InlineObject::CommentStart { id } if note.is_none() => Some(self.token(Mark::RangeStart(id))),
InlineObject::CommentEnd { id } if note.is_none() => {
let ann = if self.annotated.insert(id) { self.annotation(id) } else { None };
Some(self.token(Mark::RangeEnd { id, ann }))
}
_ => None,
};
let Some(tok) = tok else { continue };
let end = off + craft_wordmodel::para::OBJ.len_utf8();
let text = if tok.is_empty() { tok } else { format!("{MARK_OPEN}{tok}{MARK_CLOSE}") };
if q.delete(off, end).is_err() {
continue;
}
if !text.is_empty() && q.insert_text(off, &text, &Default::default()).is_err() {
continue;
}
}
q
}
fn annotation(&mut self, id: u32) -> Option<Annotation> {
let c = self.doc.comments.get(&id)?;
let part = self.doc.parts.get(&c.part)?;
let mut blocks = Vec::new();
self.story(&part.blocks, IN_STORY, 0, &mut blocks);
Some(Annotation { author: c.author.clone(), initials: c.initials.clone(), date: c.date.clone(), blocks })
}
}
/// `\sectd` and a section's formatting, in the words the reader reads back.
fn sect_words(s: &craft_wordmodel::SectionProps) -> String {
use craft_wordmodel::section::{NumFormat, SectionStart};
let tw = |v: f32| if v.is_finite() { ((v * 20.0).round() as i64).max(0) } else { 0 };
let mut o = String::from("\\sectd");
o.push_str(match s.start {
SectionStart::Continuous => "\\sbknone",
SectionStart::NextColumn => "\\sbkcol",
SectionStart::NextPage => "\\sbkpage",
SectionStart::EvenPage => "\\sbkeven",
SectionStart::OddPage => "\\sbkodd",
});
o.push_str(&format!("\\pgwsxn{}\\pghsxn{}", tw(s.page_w), tw(s.page_h)));
if s.landscape {
o.push_str("\\lndscpsxn");
}
o.push_str(&format!(
"\\marglsxn{}\\margrsxn{}\\margtsxn{}\\margbsxn{}\\headery{}\\footery{}",
tw(s.margin_left),
tw(s.margin_right),
tw(s.margin_top),
tw(s.margin_bottom),
tw(s.header),
tw(s.footer)
));
if s.columns.count > 1 {
o.push_str(&format!("\\cols{}\\colsx{}", s.columns.count, tw(s.columns.space)));
if s.columns.separator {
o.push_str("\\linebetcol");
}
}
if let Some(n) = s.page_num_start {
o.push_str(&format!("\\pgnrestart\\pgnstarts{n}"));
}
match s.page_num_format {
NumFormat::UpperRoman => o.push_str("\\pgnucrm"),
NumFormat::LowerRoman => o.push_str("\\pgnlcrm"),
NumFormat::UpperLetter => o.push_str("\\pgnucltr"),
NumFormat::LowerLetter => o.push_str("\\pgnlcltr"),
_ => {}
}
if s.title_page {
o.push_str("\\titlepg");
}
o.push('\n');
o
}
pub(super) const HEAD_SIZES: [f32; 6] = [20.0, 16.0, 14.0, 12.0, 12.0, 11.0];
fn style_num(k: Kind) -> Option<u32> {
match k {
Kind::Heading(n) => Some(u32::from(n.clamp(1, 6))),
Kind::Title => Some(7),
Kind::Quote => Some(8),
Kind::Code => Some(9),
_ => None,
}
}
fn rtf_border_style(s: craft_wordmodel::props::BorderStyle) -> &'static str {
use craft_wordmodel::props::BorderStyle;
match s {
BorderStyle::None => "\\brdrnone",
BorderStyle::Single => "\\brdrs",
BorderStyle::Double => "\\brdrdb",
BorderStyle::Dotted => "\\brdrdot",
BorderStyle::Dashed => "\\brdrdash",
BorderStyle::Thick => "\\brdrth",
BorderStyle::Triple => "\\brdrtriple",
BorderStyle::DotDash => "\\brdrdashd",
BorderStyle::Wave => "\\brdrwavy",
BorderStyle::ThinThickSmallGap => "\\brdrtnthsg",
BorderStyle::ThickThinSmallGap => "\\brdrthtnsg",
BorderStyle::ThinThickThinSmallGap => "\\brdrtnthtnsg",
BorderStyle::ThinThickMediumGap => "\\brdrtnthmg",
BorderStyle::ThickThinMediumGap => "\\brdrthtnmg",
BorderStyle::ThinThickThinMediumGap => "\\brdrtnthtnmg",
BorderStyle::ThinThickLargeGap => "\\brdrtnthlg",
BorderStyle::ThickThinLargeGap => "\\brdrthtnlg",
BorderStyle::ThinThickThinLargeGap => "\\brdrtnthtnlg",
}
}
impl Writer {
fn write_border(&mut self, prefix: &str, side: char, b: craft_wordmodel::props::Border, out: &mut String) {
out.push_str(&format!("\\{prefix}{side}"));
out.push_str(rtf_border_style(b.style));
let w = ((b.width * 20.0).round() as i64).clamp(1, 150);
out.push_str(&format!("\\brdrw{w}"));
if prefix == "brdr" {
let sp = (b.space * 20.0).round() as i64;
if sp > 0 {
out.push_str(&format!("\\brsp{sp}"));
}
}
if let Some(c) = b.color {
let i = self.t.color(c);
out.push_str(&format!("\\brdrcf{i}"));
}
}
fn cell_borders(&mut self, borders: Option<&craft_wordmodel::props::Borders>, borderless: bool, out: &mut String) {
if let Some(b) = borders {
for (side, opt) in [('t', &b.top), ('l', &b.left), ('b', &b.bottom), ('r', &b.right)] {
if let Some(border) = opt.filter(|x| x.is_visible()) {
self.write_border("clbrdr", side, border, out);
}
}
} else if !borderless {
out.push_str("\\clbrdrt\\brdrs\\brdrw10\\clbrdrl\\brdrs\\brdrw10\\clbrdrb\\brdrs\\brdrw10\\clbrdrr\\brdrs\\brdrw10");
}
}
/// `\revtbl` index of a tracked change's author (0 = "Unknown").
fn author(&mut self, a: &str) -> usize {
let authors = self.authors.get_or_insert_with(Vec::new);
let a = a.trim();
if a.is_empty() {
return 0;
}
match authors.iter().position(|x| x == a) {
Some(i) => i + 1,
None => {
authors.push(a.to_string());
authors.len()
}
}
}
/// Control words of a tracked insertion and deletion (`\revised`, `\deleted`).
fn rev_ctl(&mut self, ins: Option<&RevMark>, del: Option<&RevMark>) -> String {
let mut ctl = String::new();
if let Some(m) = ins {
ctl.push_str(&self.rev_words("\\revised\\revauth", "\\revdttm", m));
}
if let Some(m) = del {
ctl.push_str(&self.rev_words("\\deleted\\revauthdel", "\\revdttmdel", m));
}
ctl
}
/// `auth` with the `\revtbl` index of `m`'s author, then `date` with its DTTM when it has one.
fn rev_words(&mut self, auth: &str, date: &str, m: &RevMark) -> String {
let mut s = format!("{auth}{}", self.author(&m.author));
if let Some(d) = iso_to_dttm(&m.date) {
s.push_str(&format!("{date}{}", d as i32));
}
s
}
/// A run of text; place markers in it become notes and comment ranges.
fn run(&mut self, t: &str, f: &Fmt, out: &mut String) {
if !t.contains(MARK_OPEN) {
return self.run_plain(t, f, out);
}
let mut rest = t;
while !rest.is_empty() {
let Some((pre, after)) = rest.split_once(MARK_OPEN) else {
self.run_plain(rest, f, out);
break;
};
if !pre.is_empty() {
self.run_plain(pre, f, out);
}
let Some((tok, tail)) = after.split_once(MARK_CLOSE) else {
self.run_plain(after, f, out);
break;
};
self.mark(tok, out);
rest = tail;
}
}
/// The RTF for one place marker: `S` is a note's own number, a number indexes `marks`.
fn mark(&mut self, tok: &str, out: &mut String) {
if tok == "S" {
out.push_str("{\\super\\chftn}");
return;
}
let Some(m) = tok.parse::<usize>().ok().and_then(|i| self.marks.get_mut(i)) else { return };
match std::mem::replace(m, Mark::Done) {
Mark::Note { endnote, blocks } => {
out.push_str("{\\super\\chftn}{\\footnote");
if endnote {
out.push_str("\\ftnalt");
}
out.push(' ');
self.blocks(&blocks, false, "\\par", out, 0);
out.push('}');
}
Mark::RangeStart(id) => out.push_str(&format!("{{\\*\\atrfstart {id}}}")),
Mark::RangeEnd { id, ann } => {
out.push_str(&format!("{{\\*\\atrfend {id}}}"));
if let Some(a) = ann {
out.push_str(&format!(
"{{\\*\\atnid {}}}{{\\*\\atnauthor {}}}\\chatn{{\\*\\annotation{{\\*\\atnref {id}}}",
esc(&a.initials),
esc(&a.author)
));
if let Some(d) = rtf_date(&a.date) {
out.push_str(&format!("{{\\*\\atndate {d}}}"));
}
out.push('\n');
self.blocks(&a.blocks, false, "\\par", out, 0);
out.push('}');
}
}
Mark::Done => {}
}
}
fn run_plain(&mut self, t: &str, f: &Fmt, out: &mut String) {
let mut ctl = String::new();
if let Some(c) = &f.fmt_change {
// RTF 1.9.1, as Word writes it: the properties the change altered, as they were,
// then who changed it and when, all in the group. The group goes first so a control
// word, not the group, ends the run's properties.
let old = self.char_diff(&c.old, f);
let words = self.rev_words("\\crauth", "\\crdate", &c.mark);
ctl.push_str(&format!("{{\\*\\oldcprops {old}{words}}}"));
}
ctl.push_str(&self.rev_ctl(f.ins.as_ref(), f.del.as_ref()));
ctl.push_str(&self.char_ctl(f));
let body = if ctl.is_empty() { esc(t) } else { format!("{{{ctl} {}}}", esc(t)) };
// A script, `file:`, UNC or other non-web target is written as plain text, as the HTML and
// Markdown exporters do: Word follows a HYPERLINK field's target on click, and a UNC one
// leaks the reader's NTLM credentials.
match f.link.as_deref().filter(|l| craft_wordmodel::link::is_safe_href(l)) {
Some(l) => {
// In a field's quotes `\\` is one backslash; a quote is stored as `%22`.
let target = l.replace('\\', "\\\\").replace('"', "%22");
out.push_str(&format!("{{\\field{{\\*\\fldinst{{HYPERLINK \"{}\"}}}}{{\\fldrslt{{{}}}}}}}", esc(&target), body));
}
None => out.push_str(&body),
}
}
/// The character formatting control words that turn `cur` back into `old`: only the
/// properties that differ, with explicit "off" values (`\b0`, `\ulnone`, `\cf0`…).
fn char_diff(&mut self, old: &Fmt, cur: &Fmt) -> String {
// A link's underline and colour are the link's, before the change and after; what a run
// doesn't set directly is its paragraph style's, as Word writes resolved values.
let old = &styled(&self.base, &Fmt { link: cur.link.clone(), ..old.clone() });
let cur = &styled(&self.base, cur);
let mut ctl = String::new();
let underline = |f: &Fmt| f.underline || f.link.is_some();
let flags = [
(old.bold, cur.bold, "\\b", "\\b0"),
(old.italic, cur.italic, "\\i", "\\i0"),
(underline(old), underline(cur), "\\ul", "\\ulnone"),
(old.strike, cur.strike, "\\strike", "\\strike0"),
];
for (o, c, on, off) in flags {
if o != c {
ctl.push_str(if o { on } else { off });
}
}
if (old.sup, old.sub) != (cur.sup, cur.sub) {
ctl.push_str(if old.sup {
"\\super"
} else if old.sub {
"\\sub"
} else {
"\\nosupersub"
});
}
fn font(f: &Fmt) -> Option<&str> {
if f.code { Some(model::MONO_FONT) } else { f.font.as_deref() }
}
let old_font = font(old);
if old_font != font(cur) {
// No font is the document's default font, font 0 (Normal's).
let i = old_font.map_or(0, |n| self.t.font(n));
ctl.push_str(&format!("\\f{i}"));
}
let half_points = |s: Option<f32>| s.map(|s| (s * 2.0).round().clamp(2.0, 3276.0) as i32);
let old_size = half_points(old.size);
if old_size != half_points(cur.size) {
// No size is Normal's, RTF's default 12 pt.
ctl.push_str(&format!("\\fs{}", old_size.unwrap_or(24)));
}
let color = |f: &Fmt| f.color.or(if f.link.is_some() { Some(Rgb(0x05, 0x63, 0xC1)) } else { None });
let old_color = color(old);
if old_color != color(cur) {
let i = old_color.map_or(0, |c| self.t.color(c));
ctl.push_str(&format!("\\cf{i}"));
}
if old.background != cur.background {
let i = old.background.map_or(0, |c| self.t.color(c));
ctl.push_str(&format!("\\chcbpat{i}"));
}
ctl
}
/// Character formatting control words of `f` (no revision marks).
fn char_ctl(&mut self, f: &Fmt) -> String {
let mut ctl = String::new();
if f.bold {
ctl.push_str("\\b");
}
if f.italic {
ctl.push_str("\\i");
}
if f.underline || f.link.is_some() {
ctl.push_str("\\ul");
}
if f.strike {
ctl.push_str("\\strike");
}
if f.sup {
ctl.push_str("\\super");
} else if f.sub {
ctl.push_str("\\sub");
}
if f.code {
let i = self.t.font(model::MONO_FONT);
ctl.push_str(&format!("\\f{i}"));
} else if let Some(fam) = &f.font {
let i = self.t.font(fam);
ctl.push_str(&format!("\\f{i}"));
}
if let Some(s) = f.size {
ctl.push_str(&format!("\\fs{}", (s * 2.0).round().clamp(2.0, 3276.0) as i32));
}
let color = f.color.or(if f.link.is_some() { Some(Rgb(0x05, 0x63, 0xC1)) } else { None });
if let Some(c) = color {
let i = self.t.color(c);
ctl.push_str(&format!("\\cf{i}"));
}
if let Some(c) = f.background {
let i = self.t.color(c);
ctl.push_str(&format!("\\chcbpat{i}"));
}
ctl
}
fn picture(&mut self, img: &model::Img, out: &mut String) {
// A metafile's `\picw`/`\pich` are its size in 0.01 mm (#4259).
let hundredths = ((img.w * 2540.0 / 72.0).round() as i64, (img.h * 2540.0 / 72.0).round() as i64);
let (data, blip) = match img.ext.as_str() {
"png" => (img.data.to_vec(), "\\pngblip"),
"jpeg" | "jpg" => (img.data.to_vec(), "\\jpegblip"),
// The bytes decide, as on reading: an EMF starts with its header record, type 1 (a
// DOCX may store one under `.wmf`).
"emf" | "wmf" if img.data.starts_with(&[1, 0, 0, 0]) => (img.data.to_vec(), "\\emfblip"),
// RTF holds a WMF without its 22-byte placeable header.
"emf" | "wmf" => {
let data = if img.data.starts_with(&[0xD7, 0xCD, 0xC6, 0x9A]) { img.data.get(22..).unwrap_or_default() } else { img.data.as_slice() };
(data.to_vec(), "\\wmetafile8")
}
_ => {
// Re-encode other formats as PNG, bounding the decode so a tiny file declaring
// huge dimensions can't exhaust memory on export.
let Some(buf) = to_png(&img.data) else { return };
(buf, "\\pngblip")
}
};
// A placeable header with nothing after it leaves no picture to write.
if data.is_empty() {
return;
}
let (pw, ph) = match blip {
"\\emfblip" | "\\wmetafile8" => hundredths,
_ => model::image_px(&data).map(|(a, b)| (i64::from(a), i64::from(b))).unwrap_or((1, 1)),
};
out.push_str(&format!(
"{{\\pict{blip}\\picw{pw}\\pich{ph}\\picwgoal{}\\pichgoal{}\n",
(img.w * 20.0).round() as i64,
(img.h * 20.0).round() as i64
));
for chunk in data.chunks(64) {
for b in chunk {
out.push_str(&format!("{b:02x}"));
}
out.push('\n');
}
out.push('}');
}
fn inlines(&mut self, inl: &[Inline], out: &mut String) {
let inl = model::equations_as_text(inl);
for i in inl.iter() {
match i {
Inline::Text(t, f) => self.run(t, f, out),
Inline::Image(img) => self.picture(img, out),
Inline::Anchor(a) => {
let a = esc(a);
out.push_str(&format!("{{\\*\\bkmkstart {a}}}{{\\*\\bkmkend {a}}}"));
}
// RTF keeps no ruby yet: its base text, in the run's formatting.
Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out),
// ASK and SET show nothing (see `model::shows_nothing`).
Inline::Field { instr, .. } if model::shows_nothing(instr) => {}
Inline::Field { result, fmt, .. } | Inline::Control { text: result, fmt, .. } => self.run(result, fmt, out),
Inline::Figure(_)
| Inline::Chart(_)
| Inline::Equation { .. }
| Inline::Note { .. }
| Inline::Comment { .. }
| Inline::CommentEnd(_)
| Inline::Shape { .. }
| Inline::Group { .. }
| Inline::FieldStart(_)
| Inline::FieldEnd
| Inline::AnchorEnd(_) => {}
}
}
}
pub(super) fn para(&mut self, p: &Para, intbl: bool, ls: Option<(usize, String)>, end: &str, out: &mut String) {
let ctl = self.para_ctl(p, intbl);
out.push_str(&ctl);
if let Some(c) = &p.fmt_change {
// RTF 1.9.1: the paragraph formatting before the change, then who changed it and when.
let old = self.para_ctl(&c.old, intbl);
// The old list is the same list instance (only its level may differ).
let list = match (c.old.list, &ls) {
(Some(li), Some((n, _))) => format!("\\ls{n}\\ilvl{}", li.level.min(8)),
_ => String::new(),
};
out.push_str(&format!("{{\\*\\oldpprops {old}{list}}}"));
let words = self.rev_words("\\prauth", "\\prdate", &c.mark);
out.push_str(&words);
}
if p.kind != Kind::Rule
&& let Some(b) = &p.borders
{
for (side, opt) in [('t', &b.top), ('l', &b.left), ('b', &b.bottom), ('r', &b.right)] {
if let Some(border) = opt.filter(|x| x.is_visible()) {
self.write_border("brdr", side, border, out);
}
}
}
if let (Some(li), Some((n, label))) = (p.list, &ls) {
let lv = li.level.min(8);
out.push_str(&format!("\\ls{n}\\ilvl{lv}\\fi-360\\li{}", 720 * (u32::from(lv) + 1)));
// The closing brace ends the label; a space after it would be text.
out.push_str(&format!(" {{\\listtext\\pard\\plain {}\\tab}}", esc(label)));
} else {
out.push(' ');
}
self.base = style_fmt(p.kind);
self.inlines(&p.inlines, out);
self.base = Fmt::default();
// A tracked paragraph mark is the `\par` inside the revision's group.
let marks = self.rev_ctl(p.mark_ins.as_deref(), p.mark_del.as_deref());
if end == "\\par" && !marks.is_empty() {
out.push_str(&format!("{{{marks} {end}}}"));
} else {
out.push_str(end);
}
out.push('\n');
}
/// The paragraph formatting control words of `p`, from `\pard\plain` (its list left out).
fn para_ctl(&mut self, p: &Para, intbl: bool) -> String {
let mut out = String::from("\\pard\\plain");
if intbl {
out.push_str("\\intbl");
}
if let Some(s) = style_num(p.kind) {
out.push_str(&format!("\\s{s}"));
}
if let Kind::Heading(n) = p.kind {
out.push_str(&format!("\\outlinelevel{}", n.clamp(1, 6) - 1));
}
match p.align {
Some(Align::Center) => out.push_str("\\qc"),
Some(Align::Right) => out.push_str("\\qr"),
Some(Align::Justify) => out.push_str("\\qj"),
Some(Align::Distribute) => out.push_str("\\qd"),
_ => out.push_str("\\ql"),
}
if p.page_break {
out.push_str("\\pagebb");
}
match p.kind {
Kind::Quote => out.push_str("\\li720\\ri720\\i"),
Kind::Code => {
let f = self.t.font(model::MONO_FONT);
out.push_str(&format!("\\sa0\\f{f}\\fs20"));
}
Kind::Rule => out.push_str("\\brdrb\\brdrs\\brdrw15\\brsp20"),
Kind::Heading(n) => {
let sz = HEAD_SIZES.get(usize::from(n.clamp(1, 6)) - 1).copied().unwrap_or(12.0);
out.push_str(&format!("\\sb240\\sa80\\keepn\\b\\fs{}", (sz * 2.0) as i32));
}
Kind::Title => out.push_str("\\sa80\\fs56"),
Kind::Normal => {}
}
out
}
fn blocks(&mut self, blocks: &[FBlock], intbl: bool, last_end: &str, out: &mut String, depth: usize) {
let mut counter = model::ListCounter::default();
let mut cur_list: Option<usize> = None;
let mut top_ordered = false;
let n = blocks.len();
for (i, b) in blocks.iter().enumerate() {
let mut end = if i + 1 == n { last_end.to_string() } else { "\\par".to_string() };
match b {
FBlock::Para(p) => {
// A paragraph that ends a section ends it with `\sect`; the next one begins.
if p.section.is_some() && !intbl && depth == 0 && self.sect_i + 1 < self.sections.len() {
self.sect_i += 1;
end = format!("\\sect\n{}", self.sections.get(self.sect_i).map(sect_words).unwrap_or_default());
// A body that ends on the break keeps its last section: an empty paragraph holds it.
if i + 1 == n {
end.push_str("\\pard\\plain \\par");
}
}
let ls = match p.list {
Some(li) => {
// A top-level item of the other kind starts a new list.
if li.level == 0 && cur_list.is_some() && top_ordered != li.ordered {
cur_list = None;
}
if li.level == 0 {
top_ordered = li.ordered;
}
let id = match cur_list {
Some(id) => id,
None => {
// Level kinds from the whole run of list paragraphs.
let mut kinds = [li.ordered; 9];
let mut seen = [false; 9];
for q in blocks.iter().skip(i) {
let FBlock::Para(q) = q else { break };
let Some(ql) = q.list else { break };
let l = ql.level.min(8) as usize;
if l == 0 && seen[0] && kinds[0] != ql.ordered {
break;
}
if let (Some(s), Some(k)) = (seen.get_mut(l), kinds.get_mut(l))
&& !*s
{
*s = true;
*k = ql.ordered;
}
}
self.lists.push(kinds);
counter.reset();
self.lists.len()
}
};
cur_list = Some(id);
Some((id, counter.label(li)))
}
None => {
cur_list = None;
None
}
};
self.para(p, intbl, ls, &end, out);
}
FBlock::Table(t) => {
cur_list = None;
if intbl || depth > 0 {
// Nested table: its cells' paragraphs flow into this cell.
let inner: Vec<FBlock> = t.rows.iter().flatten().flat_map(|c| c.blocks.clone()).collect();
if depth < model::MAX_DEPTH {
self.blocks(&inner, intbl, &end, out, depth + 1);
}
} else {
self.table(t, out, depth);
}
}
}
}
if blocks.is_empty() && !last_end.is_empty() {
out.push_str(&format!("\\pard\\plain{} {last_end}\n", if intbl { "\\intbl" } else { "" }));
}
}
fn table(&mut self, t: &FTable, out: &mut String, depth: usize) {
let cols = t.cols().max(1);
let total = 9360.0f32; // 6.5" in twips
let widths: Vec<f32> = if t.widths.len() == cols && t.widths.iter().all(|w| w.is_finite() && *w > 0.0) {
t.widths.iter().map(|w| w * 20.0).collect()
} else {
vec![total / cols as f32; cols]
};
for row in &t.rows {
out.push_str("\\trowd\\trgaph108\\trleft0");
if !row.is_empty() && row.iter().all(|c| c.header) {
out.push_str("\\trhdr");
}
let mut g = 0usize;
let mut x = 0.0f32;
for c in row {
let span = c.colspan.clamp(1, 63) as usize;
for k in g..(g + span).min(widths.len()) {
x += widths.get(k).copied().unwrap_or(0.0);
}
g += span;
if c.covered {
out.push_str("\\clvmrg");
} else if c.rowspan > 1 {
out.push_str("\\clvmgf");
}
match c.valign {
Some(VAlign::Top) => out.push_str("\\clvertalt"),
Some(VAlign::Center) => out.push_str("\\clvertalc"),
Some(VAlign::Bottom) => out.push_str("\\clvertalb"),
None => {}
}
self.cell_borders(c.borders.as_ref(), t.borderless, out);
if let Some(s) = c.shading {
let i = self.t.color(s);
out.push_str(&format!("\\clcbpat{i}"));
}
out.push_str(&format!("\\cellx{}", x.round() as i64));
}
out.push('\n');
for c in row {
if c.covered {
out.push_str("\\pard\\plain\\intbl \\cell\n");
} else {
let blocks: Vec<FBlock> = c
.blocks
.iter()
.cloned()
.map(|b| match b {
FBlock::Para(mut p) if c.header => {
for i in &mut p.inlines {
if let Inline::Text(_, f) = i {
f.bold = true;
}
}
FBlock::Para(p)
}
other => other,
})
.collect();
self.blocks(&blocks, true, "\\cell", out, depth);
}
}
out.push_str("\\row\n");
}
}
}
fn level_def(ordered: bool, l: usize) -> String {
let indent = 720 * (l + 1);
if ordered {
let nfc = [0, 4, 2].get(l % 3).copied().unwrap_or(0);
format!(
"{{\\listlevel\\levelnfc{nfc}\\levelnfcn{nfc}\\leveljc0\\leveljcn0\\levelfollow0\\levelstartat1\\levelspace0\\levelindent0{{\\leveltext\\'02\\'{l:02x}.;}}{{\\levelnumbers\\'01;}}\\fi-360\\li{indent}\\lin{indent}}}"
)
} else {
let ch = ['\u{2022}', '\u{25E6}', '\u{25AA}'].get(l % 3).copied().unwrap_or('\u{2022}');
format!(
"{{\\listlevel\\levelnfc23\\levelnfcn23\\leveljc0\\leveljcn0\\levelfollow0\\levelstartat1\\levelspace0\\levelindent0{{\\leveltext\\'01\\u{}?;}}{{\\levelnumbers;}}\\fi-360\\li{indent}\\lin{indent}}}",
ch as u32 as i16
)
}
}
/// RTF text of a document.
pub fn export(doc: &Document) -> String {
let (marked, marks) = match mark_doc(doc) {
Some((d, m)) => (Some(d), m),
None => (None, Vec::new()),
};
let doc = marked.as_ref().unwrap_or(doc);
let flow = model::from_doc_tracked(doc);
let base_font = doc.styles.default_chr.font.clone().unwrap_or_else(|| craft_wordmodel::styles::BODY_FONT.to_string());
let mut w = Writer {
marks,
t: Tables { fonts: vec![base_font], colors: Vec::new() },
lists: Vec::new(),
authors: None,
base: Fmt::default(),
sections: Vec::new(),
sect_i: 0,
};
let mut body = String::new();
// Every section starts with its own `\sectd`; `\sect` breaks only where there are several.
let secs: Vec<craft_wordmodel::SectionProps> = doc.sections().into_iter().map(|(_, s)| s.clone()).collect();
if let Some(first) = secs.first() {
body.push_str(&sect_words(first));
}
if secs.len() > 1 {
w.sections = secs;
}
w.blocks(&flow.blocks, false, "\\par", &mut body, 0);
export_with(&flow.meta, &w, &body, &doc.last_section)
}
fn export_with(m: &Meta, w: &Writer, body: &str, sec: &craft_wordmodel::SectionProps) -> String {
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033\\uc1\n{\\fonttbl");
for (i, f) in w.t.fonts.iter().enumerate() {
let fam = if model::is_mono(f) { "\\fmodern" } else { "\\fswiss" };
out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc_font(f)));
}
out.push_str("}\n{\\colortbl;");
for c in &w.t.colors {
out.push_str(&format!("\\red{}\\green{}\\blue{};", c.0, c.1, c.2));
}
out.push_str("}\n{\\*\\generator WordCraft;}\n{\\stylesheet{\\s0\\snext0 Normal;}");
for n in 1..=6u32 {
let sz = HEAD_SIZES.get(n as usize - 1).copied().unwrap_or(12.0);
out.push_str(&format!("{{\\s{n}\\sbasedon0\\snext0\\outlinelevel{}\\keepn\\b\\fs{} heading {n};}}", n - 1, (sz * 2.0) as i32));
}
out.push_str(
"{\\s7\\sbasedon0\\snext0\\fs56 Title;}{\\s8\\sbasedon0\\snext0\\li720\\ri720\\i Quote;}{\\s9\\sbasedon0\\snext9\\sa0\\fs20 Code;}}\n",
);
if !w.lists.is_empty() {
out.push_str("{\\*\\listtable");
for (i, kinds) in w.lists.iter().enumerate() {
let id = i + 1;
out.push_str(&format!("\n{{\\list\\listtemplateid{id}\\listhybrid"));
for (l, k) in kinds.iter().enumerate() {
out.push_str(&level_def(*k, l));
}
out.push_str(&format!("{{\\listname ;}}\\listid{id}}}"));
}
out.push_str("}\n{\\*\\listoverridetable");
for i in 1..=w.lists.len() {
out.push_str(&format!("{{\\listoverride\\listid{i}\\listoverridecount0\\ls{i}}}"));
}
out.push_str("}\n");
}
if let Some(authors) = &w.authors {
out.push_str("{\\*\\revtbl {Unknown;}");
for a in authors {
out.push_str(&format!("{{{};}}", esc(&a.replace(';', ","))));
}
out.push_str("}\n");
}
out.push_str("{\\info");
for (k, v) in [("title", &m.title), ("author", &m.author), ("subject", &m.subject), ("keywords", &m.keywords), ("doccomm", &m.description)] {
if !v.is_empty() {
out.push_str(&format!("{{\\{k} {}}}", esc(v)));
}
}
if let Some(d) = rtf_date(&m.created) {
out.push_str(&format!("{{\\creatim{d}}}"));
}
out.push_str("}\n");
let tw = |v: f32| (v * 20.0).round() as i64;
let landscape = if sec.landscape { "\\landscape" } else { "" };
out.push_str(&format!(
"\\paperw{}\\paperh{}\\margl{}\\margr{}\\margt{}\\margb{}{landscape}\\viewkind4\n",
tw(sec.page_w),
tw(sec.page_h),
tw(sec.margin_left),
tw(sec.margin_right),
tw(sec.margin_top),
tw(sec.margin_bottom)
));
out.push_str(body);
out.push_str("}\n");
out
}
/// Most pixels a picture is decoded with to re-encode it as PNG.
const MAX_PNG_PIXELS: u64 = 100_000_000;
/// Most bytes the decoder may allocate for it.
const MAX_PNG_DECODE_BYTES: u64 = 512 << 20;
/// `data` (a GIF, BMP, TIFF, WebP… picture) as PNG. The decode is bounded, so a tiny file
/// declaring huge dimensions cannot exhaust memory; `None` when it does not decode.
fn to_png(data: &[u8]) -> Option<Vec<u8>> {
let (w, h) = model::image_px(data)?;
if u64::from(w).saturating_mul(u64::from(h)) > MAX_PNG_PIXELS {
return None;
}
let mut limits = image::Limits::default();
limits.max_image_width = Some(w);
limits.max_image_height = Some(h);
limits.max_alloc = Some(MAX_PNG_DECODE_BYTES);
let mut reader = image::ImageReader::new(std::io::Cursor::new(data)).with_guessed_format().ok()?;
reader.limits(limits);
let dec = reader.decode().ok()?;
let mut buf = Vec::new();
dec.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).ok()?;
Some(buf)
}
+472
View File
@@ -0,0 +1,472 @@
use super::reader::Reader;
use super::state::{CommentData, Dest, HeaderFooter, HfPages, MARK_CLOSE, MARK_OPEN, MAX_PICT, NoteData, PageGeom};
use super::{Arc, Block, Blocks, Cell, Document, FBlock, Flow, HashMap, InlineObject, Para, Paragraph, model};
/// 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, false).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: craft_wordmodel::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>)>>,
notes: Vec<NoteData>,
comments: Vec<CommentData>,
}
/// `marks`: also read footnotes, endnotes and comments (as place markers in the flow's text, for
/// [`import`] to turn into objects); without it they are skipped.
fn parse_full(bytes: &[u8], marks: bool) -> 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();
r.marks_on = marks;
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 | Dest::Atn(_)
)
{
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'\'' => {
// `from_str_radix` takes a leading `+`: a pair that isn't both hex digits
// is not an escape at all.
let h = bytes
.get(i + 2..i + 4)
.and_then(|h| std::str::from_utf8(h).ok())
.filter(|h| h.bytes().all(|b| b.is_ascii_hexdigit()))
.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;
}
// Not two hex digits: the escape is malformed. Consume the escape and
// any hex digits of a truncated pair, and leave every other byte,
// `}` or a control word's `\`, to be read as the structure it is.
None => {
i += 2;
let mut n = 0;
while n < 2 && bytes.get(i).is_some_and(u8::is_ascii_hexdigit) {
i += 1;
n += 1;
}
}
}
}
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 multibyte run ends here, and so does a note, comment, header or footer left open by a
// truncated file.
r.finish_multibyte();
r.end_sub();
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, notes: r.notes, comments: r.comments }
}
/// 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, notes, comments, .. } = parse_full(bytes, true)?;
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) = (craft_wordmodel::section::HeaderSet::default(), craft_wordmodel::section::HeaderSet::default());
for (which, blocks) in parts {
let blocks = model::part_blocks(&mut doc, blocks);
let id = doc.add_part(if which.footer { craft_wordmodel::PartKind::Footer } else { craft_wordmodel::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 craft_wordmodel::Block::Para(p) = &**b
&& p.section.is_some()
&& let craft_wordmodel::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);
}
place_notes_and_comments(&mut doc, notes, comments);
// 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(craft_wordmodel::props::LineSpacing::Multiple(1.0));
if default_font.is_some() {
Arc::make_mut(&mut doc.styles).default_chr.font = default_font;
}
Ok(doc)
}
/// Turn the place markers the reader left in the text into note references and comment ranges,
/// and add the notes' and comments' stories to `doc`.
fn place_notes_and_comments(doc: &mut Document, notes: Vec<NoteData>, comments: Vec<CommentData>) {
use craft_wordmodel::para::NoteKind;
use craft_wordmodel::{Comment, PartKind};
if notes.is_empty() && comments.is_empty() {
return;
}
// (kind, part id) per note, by index.
let mut note_parts: Vec<(NoteKind, u32)> = Vec::new();
for n in notes {
let (pk, nk, style) =
if n.endnote { (PartKind::Endnote, NoteKind::Endnote, "EndnoteText") } else { (PartKind::Footnote, NoteKind::Footnote, "FootnoteText") };
let mut blocks = model::part_blocks(doc, &n.blocks);
for b in &mut blocks {
if let Block::Para(p) = Arc::make_mut(b)
&& p.props.style.is_none()
{
p.props.style = Some(style.to_string());
}
}
note_parts.push((nk, doc.add_part(pk, blocks)));
}
// RTF range id to the comment's id in the document.
let mut by_rtf: HashMap<i64, u32> = HashMap::new();
for (k, c) in comments.into_iter().enumerate() {
let id = k as u32;
let blocks = model::part_blocks(doc, &c.blocks);
let part = doc.add_part(PartKind::Comment, blocks);
doc.comments
.insert(id, Comment { author: c.author, initials: c.initials, date: c.date, parent: None, resolved: false, part, ..Default::default() });
by_rtf.insert(i64::from(c.rtf_id), id);
}
let ctx = Placer { note_parts, by_rtf };
let mut body = std::mem::take(&mut doc.body);
ctx.blocks(&mut body, None, 0);
doc.body = body;
let ids: Vec<u32> = doc.parts.keys().copied().collect();
for id in ids {
let Some(part) = doc.parts.get_mut(&id) else { continue };
let own = ctx.note_parts.iter().find(|(_, p)| *p == id).copied();
ctx.blocks(&mut part.blocks, own, 0);
// A note starts with its own number, as Word's do.
if let Some((kind, pid)) = own
&& let Some(first) = part.blocks.first_mut()
&& let Block::Para(p) = Arc::make_mut(first)
&& !p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if *id == pid))
{
let props = Placer::ref_props(kind);
if p.insert_object(0, InlineObject::NoteRef { kind, id: pid, custom: String::new() }, &props).is_err() {
// The paragraph can't take it; the note simply has no number mark.
}
}
}
}
struct Placer {
note_parts: Vec<(craft_wordmodel::para::NoteKind, u32)>,
by_rtf: HashMap<i64, u32>,
}
impl Placer {
fn ref_props(kind: craft_wordmodel::para::NoteKind) -> craft_wordmodel::CharProps {
let style = if kind == craft_wordmodel::para::NoteKind::Endnote { "EndnoteReference" } else { "FootnoteReference" };
craft_wordmodel::CharProps { style: Some(style.into()), ..Default::default() }
}
fn blocks(&self, bl: &mut Blocks, own: Option<(craft_wordmodel::para::NoteKind, u32)>, depth: usize) {
for b in bl.iter_mut() {
let needs = match &**b {
Block::Para(p) => p.text.contains(MARK_OPEN),
Block::Table(_) => depth < 8,
};
if !needs {
continue;
}
match Arc::make_mut(b) {
Block::Para(p) => self.para(p, own),
Block::Table(t) => {
for r in &mut t.rows {
for c in &mut r.cells {
self.blocks(&mut c.blocks, own, depth + 1);
}
}
}
}
}
}
fn para(&self, p: &mut Paragraph, own: Option<(craft_wordmodel::para::NoteKind, u32)>) {
// Every pass removes one marker, so this ends; the bound is only a seat belt.
for _ in 0..100_000 {
let Some(a) = p.text.find(MARK_OPEN) else { return };
let Some(len) = p.text.get(a..).and_then(|t| t.find(MARK_CLOSE)) else { return };
let end = a + len + MARK_CLOSE.len_utf8();
let inner = p.text.get(a + MARK_OPEN.len_utf8()..a + len).unwrap_or("").to_string();
let mut chars = inner.chars();
let tag = chars.next().unwrap_or(' ');
let n = chars.as_str().parse::<i64>().unwrap_or(i64::MIN);
let (obj, props) = match tag {
'N' => match usize::try_from(n).ok().and_then(|i| self.note_parts.get(i)) {
Some(&(kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
None => (None, Default::default()),
},
'S' => match own {
Some((kind, id)) => (Some(InlineObject::NoteRef { kind, id, custom: String::new() }), Self::ref_props(kind)),
None => (None, Default::default()),
},
'B' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentStart { id }), Default::default()),
'E' => (self.by_rtf.get(&n).map(|&id| InlineObject::CommentEnd { id }), Default::default()),
_ => (None, Default::default()),
};
if p.delete(a, end).is_err() {
return;
}
if let Some(o) = obj
&& p.insert_object(a, o, &props).is_err()
{
return;
}
}
}
}
+529
View File
@@ -0,0 +1,529 @@
use super::parse::column_grid;
use super::state::{
CellDef, CommentData, Dest, HeaderFooter, MARK_CLOSE, MARK_OPEN, MAX_GROUPS, MAX_NOTE_BLOCKS, MAX_NOTES, MAX_REV_AUTHORS, MAX_SECTIONS, NoteData,
Output, PageGeom, SecFmt, St, Sub,
};
use super::styles::{StyleDef, drop_style_formatting, style_fmt};
use super::{Arc, Cell, FBlock, FTable, Fmt, HashMap, HashSet, Inline, Kind, Meta, Para, Rgb, model};
use crate::date::{dttm_to_iso, iso_date};
#[derive(Default)]
pub(super) struct Reader {
pub(super) st: St,
stack: Vec<St>,
overflow: usize,
pub(super) fonts: HashMap<i32, Arc<str>>,
pub(super) font_cur: i32,
pub(super) font_name: String,
/// Code pages of the fonts whose charset names one (`\fcharset204` → 1251).
pub(super) font_cps: HashMap<i32, u16>,
pub(super) font_cp_cur: Option<u16>,
/// The document's code page (`\ansicpg`); 0 until it names one (Windows-1252 then).
pub(super) ansicpg: u16,
/// The default font (`\deff`), whose charset applies before any `\f`.
pub(super) deff: i32,
/// The document's page (`\paperw`, `\margl`…) and the current section's formatting.
pub(super) page: PageGeom,
pub(super) sec: SecFmt,
/// The headers and footers of each section ended so far (`\sect`); `hf_parts` holds the
/// current section's.
pub(super) section_parts: Vec<Vec<(HeaderFooter, Vec<FBlock>)>>,
/// The header or footer being read: the group depth it started at, which one, and the
/// body's output, put back when the group closes.
pub(super) hf: Option<(usize, HeaderFooter, Box<Output>)>,
/// The current section's headers and footers, in order.
pub(super) hf_parts: Vec<(HeaderFooter, Vec<FBlock>)>,
/// The cell border side being described (`\clbrdrt`, `l`, `b`, `r`): its line style, width
/// and colour follow.
pub(super) cell_side: Option<char>,
/// Each table row's cell right edges (`\cellx`, twips), for the table's column grid.
table_rights: Vec<Vec<i32>>,
pub(super) colors: Vec<Option<Rgb>>,
pub(super) color_cur: (u8, u8, u8, bool),
pub(super) styles: HashMap<i32, String>,
/// Each style's own character formatting, as its `\stylesheet` entry writes it.
pub(super) style_defs: HashMap<i32, StyleDef>,
pub(super) style_cur: i32,
pub(super) style_name: String,
pub(super) style_def_cur: StyleDef,
pub(super) meta: Meta,
pub(super) info_text: String,
// Lists: list id → ordered per level; override ls → list id.
pub(super) lists: HashMap<i32, Vec<bool>>,
pub(super) list_cur: Vec<bool>,
pub(super) list_cur_id: i32,
pub(super) overrides: HashMap<i32, i32>,
pub(super) ov_list: i32,
pub(super) ov_ls: i32,
pub(super) fields: Vec<String>,
// Picture being read.
pub(super) pict_depth: Option<usize>,
pub(super) pict_hex: Vec<u8>,
pub(super) pict_half: Option<u8>,
pub(super) pict_kind: u8,
pub(super) pict_w: Option<f32>,
pub(super) pict_h: Option<f32>,
pub(super) pict_sx: f32,
pub(super) pict_sy: f32,
/// `\picw`/`\pich` as written, read for metafiles (0 when absent).
pub(super) pict_nat: (i32, i32),
/// The mapping mode `N` of `\wmetafileN`.
pub(super) pict_mm: i32,
pub(super) bkmk: Option<(usize, String)>,
/// Authors of tracked changes (`\revtbl`), and the entry being read.
rev_authors: Vec<String>,
pub(super) rev_name: String,
pub(super) skip_chars: usize,
pub(super) pending_high: Option<u16>,
/// A streaming decoder for the current multibyte code page, and the code page it was made for.
/// A lead byte and its trail byte decode together even when a text word, a group boundary or the
/// end of a chunk falls between them, so a run of any length never splits a character.
pub(super) mb_decoder: Option<encoding_rs::Decoder>,
pub(super) mb_cp: u16,
// Output.
pub(super) body: Vec<FBlock>,
pub(super) para: Para,
pub(super) para_has: bool,
table: Vec<Vec<Cell>>,
row: Vec<Cell>,
cell: Vec<FBlock>,
pub(super) defs: Vec<CellDef>,
pub(super) def_cur: CellDef,
pub(super) row_header: bool,
pub(super) created: Option<(u32, u32, u32, u32, u32)>,
pub(super) in_creatim: bool,
pub(super) rule_pending: bool,
pub(super) info_depth: Option<usize>,
// Notes and comments (only when the caller wants them).
pub(super) marks_on: bool,
pub(super) sub: Option<Sub>,
pub(super) notes: Vec<NoteData>,
pub(super) comments: Vec<CommentData>,
pub(super) atn_text: String,
pub(super) atn_initials: String,
pub(super) atn_author: String,
pub(super) atn_date: String,
pub(super) atn_parts: Option<(u32, u32, u32, u32, u32)>,
pub(super) atn_ref: Option<i32>,
pub(super) last_end: Option<i32>,
pub(super) range_starts: HashSet<i32>,
pub(super) range_ends: HashSet<i32>,
pub(super) claimed: HashSet<i32>,
pub(super) synth: i32,
}
impl Reader {
pub(super) fn depth(&self) -> usize {
self.stack.len()
}
/// Set the output aside (a header or footer starts) or put it back (it ended), returning the
/// other one.
fn swap_output(&mut self, mut o: Output) -> Output {
std::mem::swap(&mut o.body, &mut self.body);
std::mem::swap(&mut o.para, &mut self.para);
std::mem::swap(&mut o.para_has, &mut self.para_has);
std::mem::swap(&mut o.table, &mut self.table);
std::mem::swap(&mut o.row, &mut self.row);
std::mem::swap(&mut o.cell, &mut self.cell);
std::mem::swap(&mut o.defs, &mut self.defs);
std::mem::swap(&mut o.def_cur, &mut self.def_cur);
std::mem::swap(&mut o.row_header, &mut self.row_header);
std::mem::swap(&mut o.rule_pending, &mut self.rule_pending);
std::mem::swap(&mut o.table_rights, &mut self.table_rights);
o
}
/// The border of the cell side being described, else of the paragraph side.
pub(super) fn cell_border_slot(&mut self) -> Option<&mut Option<craft_wordmodel::props::Border>> {
let (b, side) = match self.cell_side {
Some(c) => (&mut self.def_cur.borders, c),
None => (&mut self.st.borders, self.st.border_side?),
};
match side {
't' => Some(&mut b.top),
'l' => Some(&mut b.left),
'b' => Some(&mut b.bottom),
'r' => Some(&mut b.right),
_ => None,
}
}
/// A header or footer group starts: read it into an output of its own.
pub(super) fn start_header_footer(&mut self, which: HeaderFooter) {
let body = self.swap_output(Output::default());
self.hf = Some((self.depth(), which, Box::new(body)));
self.st.intbl = false;
}
/// The header or footer group closed: keep what it held and go back to the body.
pub(super) fn end_header_footer(&mut self) {
let Some((_, which, body)) = self.hf.take() else { return };
if self.para_has || !self.para.inlines.is_empty() {
self.end_para(false);
}
self.close_table();
let part = self.swap_output(*body);
// A section names each one once; a repeat replaces it.
self.hf_parts.retain(|(w, _)| *w != which);
if self.hf_parts.len() < 16 {
self.hf_parts.push((which, part.body));
}
}
/// A footnote, endnote or comment group starts: read it into an output of its own.
pub(super) fn start_sub(&mut self, comment: bool) {
if self.overflow > 0 || self.notes.len() >= MAX_NOTES || self.comments.len() >= MAX_NOTES {
self.st.dest = Dest::Skip;
return;
}
let saved = self.swap_output(Output::default());
self.sub = Some(Sub { depth: self.depth(), comment, endnote: false, saved: Box::new(saved) });
self.st.intbl = false;
}
/// A place marker as text in the current paragraph.
pub(super) fn push_mark(&mut self, tag: char, n: i64, fmt: Fmt) {
self.para.push_text(&format!("{MARK_OPEN}{tag}{n}{MARK_CLOSE}"), &fmt);
self.para_has = true;
}
/// The footnote or comment group closed: keep its text and leave a marker in the body.
pub(super) fn end_sub(&mut self) {
let Some(sub) = self.sub.take() else { return };
if self.para_has || !self.para.inlines.is_empty() {
self.end_para(false);
}
self.close_table();
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
let mut inner = self.swap_output(*sub.saved);
inner.body.truncate(MAX_NOTE_BLOCKS);
if !sub.comment {
self.push_mark('N', self.notes.len() as i64, fmt);
self.notes.push(NoteData { endnote: sub.endnote, blocks: inner.body });
return;
}
let want = self.atn_ref.take().or(self.last_end.take());
let id = match want {
Some(i) if self.range_starts.contains(&i) && self.range_ends.contains(&i) && self.claimed.insert(i) => i,
_ => {
// No range of its own: the comment sits at this point.
self.synth = self.synth.saturating_sub(1);
let id = self.synth;
self.push_mark('B', i64::from(id), fmt.clone());
self.push_mark('E', i64::from(id), fmt);
id
}
};
let date =
if !self.atn_date.is_empty() { std::mem::take(&mut self.atn_date) } else { self.atn_parts.take().map(iso_date).unwrap_or_default() };
self.atn_parts = None;
self.comments.push(CommentData {
rtf_id: id,
author: std::mem::take(&mut self.atn_author),
initials: std::mem::take(&mut self.atn_initials),
date,
blocks: inner.body,
});
}
/// A comment word's text group closed (`\atrfstart 3`, `\atnauthor Ann`…).
pub(super) fn commit_atn(&mut self, kind: u8) {
let text = std::mem::take(&mut self.atn_text);
let text = text.trim();
let id = text.parse::<i32>().ok();
let fmt = self.stack.last().map(|s| s.fmt.clone()).unwrap_or_default();
match kind {
1 => {
if let Some(id) = id {
self.range_starts.insert(id);
self.push_mark('B', i64::from(id), fmt);
}
}
2 => {
if let Some(id) = id {
self.range_ends.insert(id);
self.last_end = Some(id);
self.push_mark('E', i64::from(id), fmt);
}
}
3 => self.atn_initials = text.to_string(),
4 => self.atn_author = text.to_string(),
5 => {
// Either `\yr\mo\dy\hr\min` words (read as they came) or a packed date-time number.
if let Some(parts) = self.atn_parts.take() {
self.atn_date = iso_date(parts);
} else if let Some(n) = text.parse::<u32>().ok().filter(|n| *n != 0) {
self.atn_date = iso_date((1900 + ((n >> 20) & 0x1FF), (n >> 16) & 0xF, (n >> 11) & 0x1F, (n >> 6) & 0x1F, n & 0x3F));
}
}
6 => self.atn_ref = id,
_ => {}
}
}
/// `\sect`: end the paragraph, and with it the section, which takes its formatting and the
/// headers and footers read since the last break. Inside a header, footer or table, and past
/// [`MAX_SECTIONS`], it only ends the paragraph.
pub(super) fn end_section(&mut self) {
self.end_para_mark();
if self.hf.is_some() || self.sub.is_some() || self.st.intbl || self.section_parts.len() + 1 >= MAX_SECTIONS {
return;
}
let props = self.sec.props(&self.page);
if let Some(FBlock::Para(p)) = self.body.last_mut() {
p.section = Some(Box::new(props));
self.section_parts.push(std::mem::take(&mut self.hf_parts));
}
}
/// A section formatting word: applied to the current section, unless it's inside a header
/// or footer (whose `\sectd` isn't the body's). False when `w` isn't one.
pub(super) fn section_word(&mut self, w: &str, p: Option<i32>) -> bool {
use craft_wordmodel::section::{NumFormat, SectionStart};
let on = p != Some(0);
let pv = p.unwrap_or(0);
// Inside a header or footer the word is read into a scratch copy and dropped.
let mut scratch;
let sec = if self.hf.is_some() || self.sub.is_some() {
scratch = self.sec.clone();
&mut scratch
} else {
&mut self.sec
};
match w {
"sectd" => *sec = SecFmt::default(),
"titlepg" => sec.title_page = on,
"pgwsxn" => sec.page.w = Some(pv),
"pghsxn" => sec.page.h = Some(pv),
"marglsxn" => sec.page.left = Some(pv),
"margrsxn" => sec.page.right = Some(pv),
"margtsxn" => sec.page.top = Some(pv),
"margbsxn" => sec.page.bottom = Some(pv),
"headery" => sec.page.header = Some(pv),
"footery" => sec.page.footer = Some(pv),
"lndscpsxn" => sec.page.landscape = on,
"sbknone" => sec.start = SectionStart::Continuous,
"sbkcol" => sec.start = SectionStart::NextColumn,
"sbkpage" => sec.start = SectionStart::NextPage,
"sbkeven" => sec.start = SectionStart::EvenPage,
"sbkodd" => sec.start = SectionStart::OddPage,
"cols" => sec.cols = Some(pv),
"colsx" => sec.colsx = Some(pv),
"linebetcol" => sec.linebetcol = on,
"pgnrestart" => sec.pgn_restart = true,
"pgncont" => sec.pgn_restart = false,
"pgnstarts" => sec.pgn_start = Some(pv),
"pgndec" => sec.pgn_fmt = NumFormat::Decimal,
"pgnucrm" => sec.pgn_fmt = NumFormat::UpperRoman,
"pgnlcrm" => sec.pgn_fmt = NumFormat::LowerRoman,
"pgnucltr" => sec.pgn_fmt = NumFormat::UpperLetter,
"pgnlcltr" => sec.pgn_fmt = NumFormat::LowerLetter,
_ => return false,
}
true
}
pub(super) fn open(&mut self) {
self.skip_chars = 0;
if self.stack.len() >= MAX_GROUPS {
self.overflow += 1;
return;
}
self.stack.push(self.st.clone());
}
pub(super) fn close(&mut self) {
// A group end decodes the multibyte bytes read in it, before the state is restored.
self.finish_multibyte();
self.skip_chars = 0;
if self.overflow > 0 {
self.overflow -= 1;
return;
}
if self.sub.as_ref().is_some_and(|s| s.depth == self.depth()) {
self.end_sub();
}
let closing = self.st.dest;
// Destination-level commits.
match closing {
Dest::FontTbl if !self.font_name.is_empty() => self.commit_font(),
Dest::StyleSheet if !self.style_name.is_empty() => self.commit_style(),
Dest::RevTbl if !self.rev_name.trim().is_empty() => self.commit_author(),
Dest::Info(k) => {
let t = std::mem::take(&mut self.info_text).trim().to_string();
match k {
1 => self.meta.title = t,
2 => self.meta.author = t,
3 => self.meta.subject = t,
4 => self.meta.keywords = t,
5 => self.meta.description = t,
_ => {}
}
self.in_creatim = false;
}
Dest::Atn(k) => self.commit_atn(k),
_ => {}
}
let Some(mut prev) = self.stack.pop() else { return };
// The end of old formatting read in the body: the formatting it holds is the change's
// old formatting (nested old-formatting groups only change their parent's copy).
if let Dest::OldProps(para) = closing
&& prev.dest == Dest::Body
{
if para {
prev.chg_p.old = Some(Box::new(self.para_props(&self.st)));
} else {
prev.chg_c.old = Some(Box::new(std::mem::take(&mut self.st.old_words)));
if let Some(a) = self.st.old_rev.0 {
prev.chg_c.auth = a;
}
if let Some(d) = self.st.old_rev.1 {
prev.chg_c.date = d;
}
}
}
if self.pict_depth.is_some_and(|d| self.depth() < d) {
self.pict_depth = None;
self.finish_pict();
}
if let Some((d, _)) = &self.bkmk
&& self.depth() < *d
&& let Some((_, name)) = self.bkmk.take()
{
let name = name.trim().to_string();
if !name.is_empty() {
self.para.inlines.push(Inline::Anchor(name));
self.para_has = true;
}
}
self.st = prev;
if self.hf.as_ref().is_some_and(|(d, _, _)| self.depth() < *d) {
self.end_header_footer();
}
}
pub(super) fn commit_author(&mut self) {
let name = std::mem::take(&mut self.rev_name).trim().to_string();
if self.rev_authors.len() < MAX_REV_AUTHORS {
self.rev_authors.push(name);
}
}
/// The tracked-change mark for `\revtbl` author index `i` and DTTM `dttm`; an index outside
/// the table is "Unknown".
pub(super) fn rev_mark(&self, i: i32, dttm: u32) -> model::RevMark {
let author = usize::try_from(i).ok().and_then(|i| self.rev_authors.get(i)).cloned().unwrap_or_else(|| "Unknown".to_string());
model::RevMark { author, date: dttm_to_iso(dttm).unwrap_or_default(), id: None }
}
/// `\par` (or `\sect`): end the paragraph, whose mark is tracked as inserted or deleted when the text
/// at it is.
pub(super) fn end_para_mark(&mut self) {
let r = self.st.rev;
self.para.mark_ins = r.ins.then(|| Box::new(self.rev_mark(r.auth, r.dttm)));
self.para.mark_del = r.del.then(|| Box::new(self.rev_mark(r.authdel, r.dttmdel)));
self.end_para(false);
}
/// End the current paragraph (`\par`, `\cell`).
pub(super) fn end_para(&mut self, in_cell: bool) {
let read = std::mem::take(&mut self.para);
let props = self.para_props(&self.st);
let mut p = Para { inlines: read.inlines, mark_ins: read.mark_ins, mark_del: read.mark_del, ..props };
if p.kind == Kind::Normal && p.inlines.is_empty() && self.rule_pending {
p.kind = Kind::Rule;
}
self.rule_pending = false;
let c = &self.st.chg_p;
if let Some(old) = &c.old {
p.fmt_change = Some(Box::new(model::FmtChange { mark: self.rev_mark(c.auth, c.date), old: (**old).clone() }));
}
// Headings, code, titles and quotes keep their style formatting implicit, in the text's
// formatting and in the formatting before a tracked change alike. The style is the
// file's when its name gave the kind (not an outline level on another style).
let base = if self.style_kind(&self.st) == Some(p.kind) { self.style_base(p.kind, self.st.style) } else { style_fmt(p.kind) };
for i in &mut p.inlines {
if let Inline::Text(_, f) = i {
drop_style_formatting(p.kind, &base, f);
if let Some(c) = f.fmt_change.as_deref_mut() {
drop_style_formatting(p.kind, &base, &mut c.old);
}
}
}
self.para_has = false;
if self.st.intbl || in_cell {
self.cell.push(FBlock::Para(p));
} else {
self.close_table();
self.body.push(FBlock::Para(p));
}
}
pub(super) fn end_cell(&mut self) {
self.end_para(true);
let blocks = std::mem::take(&mut self.cell);
if self.row.len() < 63 {
self.row.push(Cell { blocks, ..Default::default() });
}
}
pub(super) fn end_row(&mut self) {
if self.para_has {
self.end_cell();
}
let mut cells = std::mem::take(&mut self.row);
let defs = self.defs.clone();
let mut out: Vec<Cell> = Vec::new();
let mut rights: Vec<i32> = Vec::new();
for (i, mut c) in cells.drain(..).enumerate() {
let d = defs.get(i).cloned().unwrap_or_default();
// A merged-in cell widens the cell it joins.
if d.hmerge == 2
&& let Some(last) = rights.last_mut()
{
*last = d.right;
} else {
rights.push(d.right);
}
c.shading = d.shading;
c.borders = d.borders.any_visible().then_some(d.borders);
c.header = self.row_header;
c.valign = d.valign;
match d.vmerge {
1 => c.rowspan = 2,
2 => c.covered = true,
_ => {}
}
if d.hmerge == 2
&& let Some(prev) = out.last_mut()
{
prev.colspan = (prev.colspan + 1).min(63);
continue;
}
out.push(c);
}
if !out.is_empty() && self.table.len() < craft_wordmodel::table::MAX_ROWS {
rights.truncate(out.len());
self.table.push(out);
self.table_rights.push(rights);
}
}
pub(super) fn close_table(&mut self) {
if !self.row.is_empty() || !self.cell.is_empty() {
if !self.cell.is_empty() {
let blocks = std::mem::take(&mut self.cell);
self.row.push(Cell { blocks, ..Default::default() });
}
self.end_row();
}
if !self.table.is_empty() {
let mut rows = std::mem::take(&mut self.table);
let rights = std::mem::take(&mut self.table_rights);
let widths = column_grid(&mut rows, &rights);
// Word draws only the borders the cells name: no grid style, each cell its own.
self.body.push(FBlock::Table(FTable { rows, widths, borderless: true, extra: None, kept: None, wrap: None }));
}
}
}
+323
View File
@@ -0,0 +1,323 @@
use super::{Align, Cell, FBlock, Fmt, Para, Rgb, VAlign};
/// Deepest group nesting we track; deeper groups share the innermost state.
pub(super) const MAX_GROUPS: usize = 512;
/// Largest picture we decode (bytes).
pub(super) const MAX_PICT: usize = 64 << 20;
/// Most notes, and most comments, one file may bring in.
pub(super) const MAX_NOTES: usize = 5_000;
/// Most blocks kept of one note's or comment's text.
pub(super) const MAX_NOTE_BLOCKS: usize = 500;
/// Brackets of a place marker in text: a note reference or a comment range end travels through the
/// flow model as `OPEN tag number CLOSE` (private-use characters, which both the reader and the
/// writer keep out of real text) and becomes an inline object once the document exists.
pub(super) const MARK_OPEN: char = '\u{F8F0}';
pub(super) const MARK_CLOSE: char = '\u{F8F1}';
/// Most sections we read (`\sect`); later breaks end paragraphs only.
pub(super) const MAX_SECTIONS: usize = 10_000;
// ---------------------------------------------------------------------------------------------
// Import
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum Dest {
Body,
Skip,
FontTbl,
ColorTbl,
StyleSheet,
Info(u8),
ListTable,
ListOverride,
FldInst,
Pict,
Bookmark,
/// A comment's text-valued word: 1 `\atrfstart`, 2 `\atrfend`, 3 `\atnid`, 4 `\atnauthor`,
/// 5 `\atndate`/`\atntime`, 6 `\atnref`.
Atn(u8),
/// The author table of tracked changes (`\revtbl`).
RevTbl,
/// Formatting before a tracked change (`\oldpprops` when true, else `\oldcprops`): read
/// into the group's own state, which the group's end keeps as the change's old formatting.
OldProps(bool),
}
/// A footnote, endnote or comment group being read into an output of its own.
pub(super) struct Sub {
pub(super) depth: usize,
pub(super) comment: bool,
pub(super) endnote: bool,
pub(super) saved: Box<Output>,
}
/// A note read: its kind and text.
pub(super) struct NoteData {
pub(super) endnote: bool,
pub(super) blocks: Vec<FBlock>,
}
/// A comment read: the id its range markers carry, who wrote it and when, and its text.
pub(super) struct CommentData {
pub(super) rtf_id: i32,
pub(super) author: String,
pub(super) initials: String,
pub(super) date: String,
pub(super) blocks: Vec<FBlock>,
}
/// A tracked formatting change being read (`\crauth`/`\crdate` with `\oldcprops`,
/// `\prauth`/`\prdate` with `\oldpprops`): author (`\revtbl` index), date (DTTM) and the old
/// formatting, once its group has been read.
#[derive(Clone, Debug, Default)]
pub(super) struct FmtRevSt<T> {
pub(super) auth: i32,
pub(super) date: u32,
pub(super) old: Option<Box<T>>,
}
/// Character formatting words of an `\oldcprops` group, with their parameters.
type OldWords = Vec<(String, Option<i32>)>;
/// Most words kept from one `\oldcprops` group (hostile input).
pub(super) const MAX_OLD_WORDS: usize = 64;
/// Most authors read from a `\revtbl` (hostile input).
pub(super) const MAX_REV_AUTHORS: usize = 10_000;
/// Tracked-change character properties: `\revised` / `\deleted`, their authors (`\revtbl`
/// indices) and dates (DTTM).
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct RevSt {
pub(super) ins: bool,
pub(super) del: bool,
pub(super) auth: i32,
pub(super) authdel: i32,
pub(super) dttm: u32,
pub(super) dttmdel: u32,
}
/// Which header or footer a `\header…`/`\footer…` group is: footer or header, and the pages
/// (`\headerf` first, `\headerl` left = even, `\header`/`\headerr` the others).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct HeaderFooter {
pub(super) footer: bool,
pub(super) pages: HfPages,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum HfPages {
Default,
First,
Even,
}
/// The reader's output so far, set aside while a header or footer is read.
#[derive(Default)]
pub(super) struct Output {
pub(super) body: Vec<FBlock>,
pub(super) para: Para,
pub(super) para_has: bool,
pub(super) table: Vec<Vec<Cell>>,
pub(super) row: Vec<Cell>,
pub(super) cell: Vec<FBlock>,
pub(super) defs: Vec<CellDef>,
pub(super) def_cur: CellDef,
pub(super) row_header: bool,
pub(super) rule_pending: bool,
pub(super) table_rights: Vec<Vec<i32>>,
}
#[derive(Clone, Debug)]
pub(super) struct St {
pub(super) dest: Dest,
pub(super) fmt: Fmt,
pub(super) rev: RevSt,
/// Tracked change of the character formatting (reset by `\plain`). Its old formatting is
/// the `\oldcprops` words, applied on top of the run's formatting when text is read: Word
/// writes only the properties that changed there.
pub(super) chg_c: FmtRevSt<OldWords>,
/// While an `\oldcprops` group is read: its character formatting words, and the author and
/// date of the change (`\crauth`/`\crdate`, which Word writes inside the group).
pub(super) old_words: OldWords,
pub(super) old_rev: (Option<i32>, Option<u32>),
/// Tracked change of the paragraph formatting (reset by `\pard`).
pub(super) chg_p: FmtRevSt<Para>,
pub(super) uc: usize,
pub(super) hidden: bool,
// Paragraph properties.
pub(super) align: Option<Align>,
pub(super) style: i32,
pub(super) intbl: bool,
pub(super) ls: Option<i32>,
pub(super) ilvl: u8,
pub(super) outline: Option<u8>,
pub(super) pagebb: bool,
pub(super) field: Option<usize>,
/// The code page of the current font's charset (`\fcharset`), when it names one.
pub(super) cp: Option<u16>,
/// Space before/after (`\sb`, `\sa`), line spacing (`\sl`, `\slmult`) and indents
/// (`\li`, `\ri`, `\fi`), twips.
/// The paragraph's borders (`\brdrt`, `\brdrb`, `\brdrl`, `\brdrr`) and the side being
/// described.
pub(super) borders: craft_wordmodel::props::Borders,
pub(super) border_side: Option<char>,
pub(super) sb: Option<i32>,
pub(super) sa: Option<i32>,
pub(super) sl: i32,
pub(super) slmult: bool,
pub(super) li: Option<i32>,
pub(super) ri: Option<i32>,
pub(super) fi: Option<i32>,
}
impl Default for St {
fn default() -> Self {
St {
dest: Dest::Body,
fmt: Fmt::default(),
rev: RevSt::default(),
chg_c: FmtRevSt::default(),
chg_p: FmtRevSt::default(),
old_words: Vec::new(),
old_rev: (None, None),
uc: 1,
hidden: false,
align: None,
style: 0,
intbl: false,
ls: None,
ilvl: 0,
outline: None,
pagebb: false,
field: None,
cp: None,
borders: Default::default(),
border_side: None,
sb: None,
sa: None,
sl: 0,
slmult: false,
li: None,
ri: None,
fi: None,
}
}
}
#[derive(Default, Clone)]
pub(super) struct CellDef {
pub(super) vmerge: u8,
pub(super) hmerge: u8,
pub(super) shading: Option<Rgb>,
pub(super) right: i32,
/// The borders the cell names (`\clbrdrt` … `\clbrdrr`, each with its line style, `\brdrw`
/// width and `\brdrcf` colour).
pub(super) borders: craft_wordmodel::props::Borders,
pub(super) valign: Option<VAlign>,
}
/// Page size and margins in twips (`\paperw`, `\margl`… and their `…sxn` section forms).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct PageGeom {
pub(super) w: Option<i32>,
pub(super) h: Option<i32>,
pub(super) left: Option<i32>,
pub(super) right: Option<i32>,
pub(super) top: Option<i32>,
pub(super) bottom: Option<i32>,
pub(super) header: Option<i32>,
pub(super) footer: Option<i32>,
pub(super) landscape: bool,
}
impl PageGeom {
/// The section properties these give, Word's defaults where the file names none.
fn section(&self) -> craft_wordmodel::SectionProps {
let mut s = craft_wordmodel::SectionProps::default();
// Twips to points, ignoring nonsense (zero, negative, or beyond Word's 22").
let pt = |v: Option<i32>, lo: f32, hi: f32| v.map(|t| t as f32 / 20.0).filter(|v| v.is_finite() && (lo..=hi).contains(v));
if let Some(w) = pt(self.w, 72.0, 1584.0) {
s.page_w = w;
}
if let Some(h) = pt(self.h, 72.0, 1584.0) {
s.page_h = h;
}
if self.landscape && s.page_w < s.page_h {
std::mem::swap(&mut s.page_w, &mut s.page_h);
}
s.landscape = s.page_w > s.page_h;
for (v, f) in [
(self.left, &mut s.margin_left),
(self.right, &mut s.margin_right),
(self.top, &mut s.margin_top),
(self.bottom, &mut s.margin_bottom),
(self.header, &mut s.header),
(self.footer, &mut s.footer),
] {
if let Some(v) = pt(v, 0.0, 720.0) {
*f = v;
}
}
// Margins that leave no room for text fall back to Word's.
let d = craft_wordmodel::SectionProps::default();
if s.page_w - s.margin_left - s.margin_right < 72.0 {
(s.margin_left, s.margin_right) = (d.margin_left, d.margin_right);
}
if s.page_h - s.margin_top - s.margin_bottom < 72.0 {
(s.margin_top, s.margin_bottom) = (d.margin_top, d.margin_bottom);
}
s
}
}
/// Section formatting (`\sectd` resets it): the section's page words (`\pgwsxn`…, over the
/// document's `\paperw`…), its break, columns, page numbering and title page.
#[derive(Clone, Debug, Default)]
pub(super) struct SecFmt {
pub(super) page: PageGeom,
pub(super) start: craft_wordmodel::section::SectionStart,
pub(super) cols: Option<i32>,
pub(super) colsx: Option<i32>,
pub(super) linebetcol: bool,
pub(super) pgn_restart: bool,
pub(super) pgn_start: Option<i32>,
pub(super) pgn_fmt: craft_wordmodel::section::NumFormat,
pub(super) title_page: bool,
}
impl SecFmt {
/// The section's properties over the document's page (`doc`); headers and footers unset.
pub(super) fn props(&self, doc: &PageGeom) -> craft_wordmodel::SectionProps {
let p = &self.page;
let page = PageGeom {
w: p.w.or(doc.w),
h: p.h.or(doc.h),
left: p.left.or(doc.left),
right: p.right.or(doc.right),
top: p.top.or(doc.top),
bottom: p.bottom.or(doc.bottom),
header: p.header.or(doc.header),
footer: p.footer.or(doc.footer),
// A section that gives its own page size or orientation has its own orientation;
// the document's `\landscape` only applies to sections that give neither.
landscape: if p.w.is_some() || p.h.is_some() || p.landscape { p.landscape } else { doc.landscape },
};
let mut s = page.section();
s.start = self.start;
if let Some(n) = self.cols {
s.columns.count = n.clamp(1, 45) as u32;
}
if let Some(x) = self.colsx {
s.columns.space = (x as f32 / 20.0).clamp(0.0, 1584.0);
}
s.columns.separator = self.linebetcol;
if self.pgn_restart {
s.page_num_start = Some(self.pgn_start.unwrap_or(1).clamp(0, 32_767) as u32);
}
s.page_num_format = self.pgn_fmt;
s.title_page = self.title_page;
s
}
}
+273
View File
@@ -0,0 +1,273 @@
use super::export::HEAD_SIZES;
use super::state::St;
use super::{Fmt, Kind, ListInfo, Para, Reader, model};
impl Reader {
pub(super) fn commit_style(&mut self) {
let name = std::mem::take(&mut self.style_name).trim().trim_end_matches(';').trim().to_string();
let def = std::mem::take(&mut self.style_def_cur);
if self.styles.len() < 4096 {
self.styles.insert(self.style_cur, name);
self.style_defs.insert(self.style_cur, def);
}
// The next entry is style 0 (Normal) unless it names a number.
self.style_cur = 0;
}
/// The character formatting style `id` gives its runs in this file: its stylesheet entry's,
/// then its `\sbasedon` ancestors' (a bounded walk: loops and missing ids end it).
fn file_style(&self, id: i32) -> StyleDef {
let mut out = StyleDef::default();
let mut seen: Vec<i32> = Vec::new();
let mut cur = Some(id);
while let Some(i) = cur {
if seen.len() >= 16 || seen.contains(&i) {
break;
}
seen.push(i);
let Some(d) = self.style_defs.get(&i) else { break };
out.bold = out.bold.or(d.bold);
out.italic = out.italic.or(d.italic);
out.size = out.size.or(d.size);
out.font = out.font.or(d.font);
cur = d.based_on;
}
out
}
/// The formatting a paragraph of kind `kind` in style `id` gives its runs. A style whose
/// entry and `\sbasedon` chain set no character formatting (a bare `{\s2 heading 2;}`) has
/// WordCraft's own (`style_fmt`); otherwise what the chain doesn't set is RTF's default (not
/// bold, not italic, 12 pt). The font alone falls back per property: WordCraft writes the code
/// font on each code paragraph, not in its Code entry.
pub(super) fn style_base(&self, kind: Kind, id: i32) -> Fmt {
let mut f = style_fmt(kind);
let d = self.file_style(id);
if d.bold.is_some() || d.italic.is_some() || d.size.is_some() || d.font.is_some() {
f.bold = d.bold.unwrap_or(false);
f.italic = d.italic.unwrap_or(false);
f.size = Some(d.size.unwrap_or(12.0));
}
// A font is matched the way `\f` sets a run's: a monospaced one is the code font.
if let Some(name) = d.font.and_then(|i| self.fonts.get(&i)) {
f.code = model::is_mono(name);
f.font = (!f.code).then(|| name.clone());
}
f
}
/// The paragraph kind `st`'s style names, if it names one.
pub(super) fn style_kind(&self, st: &St) -> Option<Kind> {
let name = self.styles.get(&st.style).map(|s| s.to_ascii_lowercase()).unwrap_or_default();
if let Some(n) = name.strip_prefix("heading").and_then(|r| r.trim().parse::<u8>().ok()) {
return Some(Kind::Heading(n.clamp(1, 6)));
}
if st.style != 0 {
match name.as_str() {
"title" => return Some(Kind::Title),
"quote" | "intense quote" => return Some(Kind::Quote),
"code" | "html preformatted" | "plain text" => return Some(Kind::Code),
_ => {}
}
}
None
}
fn para_kind(&self, st: &St) -> Kind {
if let Some(k) = self.style_kind(st) {
return k;
}
if let Some(o) = st.outline.filter(|o| *o < 9) {
return Kind::Heading((o + 1).min(6));
}
Kind::Normal
}
/// The paragraph formatting of `st` (kind, alignment, page break, spacing, borders, list).
pub(super) fn para_props(&self, st: &St) -> Para {
let mut p = Para::new(self.para_kind(st));
p.align = st.align;
p.page_break = st.pagebb;
p.spacing = spacing(st);
p.borders = st.borders.any_visible().then_some(st.borders);
if let Some(ls) = st.ls {
let list = self.overrides.get(&ls).and_then(|id| self.lists.get(id));
let lv = st.ilvl.min(8);
let ordered = list.and_then(|l| l.get(lv as usize).copied()).unwrap_or(false);
p.list = Some(ListInfo { ordered, level: lv, start: 1, ..Default::default() });
}
p
}
}
/// The character formatting the paragraph style of `kind` gives its runs, as the writer's
/// stylesheet and paragraph properties set it.
pub(super) fn style_fmt(kind: Kind) -> Fmt {
match kind {
Kind::Heading(n) => Fmt { bold: true, size: HEAD_SIZES.get(usize::from(n.clamp(1, 6)) - 1).copied(), ..Default::default() },
Kind::Title => Fmt { size: Some(28.0), ..Default::default() },
Kind::Quote => Fmt { italic: true, ..Default::default() },
Kind::Code => Fmt { code: true, size: Some(10.0), ..Default::default() },
Kind::Normal | Kind::Rule => Fmt::default(),
}
}
/// `f` with what it doesn't set directly taken from its paragraph style's formatting `base`.
pub(super) fn styled(base: &Fmt, f: &Fmt) -> Fmt {
let mut e = f.clone();
e.bold |= base.bold;
e.italic |= base.italic;
if e.size.is_none() {
e.size = base.size;
}
if !e.code && e.font.is_none() {
e.code = base.code;
}
e
}
/// A `\stylesheet` entry's character formatting; `None` where the entry doesn't set it.
#[derive(Clone, Copy, Debug, Default)]
pub(super) struct StyleDef {
pub(super) based_on: Option<i32>,
pub(super) bold: Option<bool>,
pub(super) italic: Option<bool>,
pub(super) size: Option<f32>,
/// A `\fonttbl` index, named when it's used (the table may follow).
pub(super) font: Option<i32>,
}
/// Clear from `f` the formatting a paragraph of kind `kind` gets from its style, whose
/// formatting is `base`: a heading's or title's size and bold when they equal the style's, a
/// quote's italic, a code run's font when it's the style's (and, as before, its size and bold).
pub(super) fn drop_style_formatting(kind: Kind, base: &Fmt, f: &mut Fmt) {
match kind {
Kind::Heading(_) | Kind::Title => {
if f.size.is_some() && f.size == base.size {
f.size = None;
}
if base.bold {
f.bold = false;
}
}
Kind::Code => {
f.size = None;
f.bold = false;
let same_font = match (&f.font, &base.font) {
(Some(a), Some(b)) => a.eq_ignore_ascii_case(b),
(a, b) => a.is_none() && b.is_none(),
};
if f.code == base.code && same_font {
f.code = false;
f.font = None;
}
}
Kind::Quote => {
if base.italic {
f.italic = false;
}
}
Kind::Normal | Kind::Rule => {}
}
}
/// A character or paragraph formatting word: what old formatting (`\oldcprops`,
/// `\oldpprops`) may hold.
pub(super) fn is_format_word(w: &str) -> bool {
matches!(
w,
"plain"
| "b"
| "i"
| "ulnone"
| "strike"
| "striked"
| "super"
| "up"
| "sub"
| "dn"
| "nosupersub"
| "fs"
| "f"
| "cf"
| "highlight"
| "chcbpat"
| "cb"
| "v"
| "pard"
| "ql"
| "qc"
| "qr"
| "qj"
| "qd"
| "s"
| "outlinelevel"
| "pagebb"
| "intbl"
| "ls"
| "ilvl"
| "sb"
| "sa"
| "sl"
| "slmult"
| "li"
| "lin"
| "ri"
| "rin"
| "fi"
| "brdrw"
| "brdrcf"
) || (w.starts_with("ul") && w.len() <= 8)
|| w.starts_with("brdr")
}
/// A character formatting word: what an `\oldcprops` group's words are replayed from.
pub(super) fn is_char_format_word(w: &str) -> bool {
matches!(
w,
"plain"
| "b"
| "i"
| "strike"
| "striked"
| "super"
| "up"
| "sub"
| "dn"
| "nosupersub"
| "fs"
| "f"
| "cf"
| "highlight"
| "chcbpat"
| "cb"
| "v"
) || (w.starts_with("ul") && w.len() <= 8)
}
/// The spacing and indents in points of the paragraph formatting `st`.
fn spacing(st: &St) -> model::Spacing {
use craft_wordmodel::props::LineSpacing;
let pt = |v: i32| (v as f32 / 20.0).clamp(-1584.0, 1584.0);
let gap = |v: Option<i32>| v.map(|v| pt(v).max(0.0));
let sl = st.sl;
let line = if sl == 0 {
None
} else if sl < 0 {
Some(LineSpacing::Exactly(pt(sl.saturating_neg()).max(1.0)))
} else if st.slmult {
// \sl240\slmult1 is single spacing.
Some(LineSpacing::Multiple((sl as f32 / 240.0).clamp(0.06, 132.0)))
} else {
Some(LineSpacing::AtLeast(pt(sl)))
};
model::Spacing {
before: gap(st.sb),
after: gap(st.sa),
line,
left: st.li.map(pt),
right: st.ri.map(pt),
first: st.fi.map(pt),
..Default::default()
}
}
File diff suppressed because it is too large. Load diff
+332
View File
@@ -0,0 +1,332 @@
use super::state::{Dest, MARK_CLOSE, MARK_OPEN, MAX_PICT, St};
use super::{Fmt, Inline, Reader, Rgb, make_img, model};
use std::sync::Arc;
/// The encoding of an East Asian multibyte code page (932 Shift-JIS, 936 GBK, 949 UHC, 950 Big5),
/// which [`Reader::finish_multibyte`] decodes a run with; `None` for the single-byte code pages.
fn multibyte_encoding(code_page: u16) -> Option<&'static encoding_rs::Encoding> {
let label = crate::codepage::multibyte_encoding_name(code_page)?;
encoding_rs::Encoding::for_label(label.as_bytes())
}
impl Reader {
pub(super) fn commit_font(&mut self) {
let name = std::mem::take(&mut self.font_name).trim().to_string();
if let Some(name) = model::kept_font(&name).filter(|n| !n.is_empty())
&& self.fonts.len() < 4096
{
self.fonts.insert(self.font_cur, name);
if let Some(cp) = self.font_cp_cur.take() {
self.font_cps.insert(self.font_cur, cp);
}
}
}
/// The code page 8-bit text is read in: the current font's, else the default font's, else the
/// document's (Windows-1252 when none is named).
fn code_page(&self) -> u16 {
self.st.cp.or_else(|| self.font_cps.get(&self.deff).copied()).unwrap_or(if self.ansicpg == 0 { 1252 } else { self.ansicpg })
}
/// A byte of 8-bit text (`\'hh` or raw): a single-byte code page decodes it at once; a
/// multibyte one is fed to a streaming decoder ([`Reader::finish_multibyte`]), so a lead byte decodes
/// together with its trail byte.
pub(super) fn byte(&mut self, b: u8) {
if self.skip_chars > 0 {
self.skip_chars -= 1;
return;
}
// `\'3b` in a font name is a `;` in every code page, and part of the name (see `ch`).
if b == b';' && self.st.dest == Dest::FontTbl {
self.ch(';');
return;
}
let cp = self.code_page();
let Some(encoding) = multibyte_encoding(cp) else {
self.finish_multibyte();
let c = crate::codepage::code_page_byte(cp, b);
self.ch(c);
return;
};
// A code page switch ends the previous run (a lead byte left then becomes U+FFFD, as Word
// writes it) before the new decoder starts.
if self.mb_cp != cp || self.mb_decoder.is_none() {
self.finish_multibyte();
self.mb_cp = cp;
self.mb_decoder = Some(encoding.new_decoder_without_bom_handling());
}
self.decode_run(&[b]);
}
/// Feed 8-bit text bytes to the open multibyte decoder and append what it emits. A single
/// `\'hh` byte decodes into a stack buffer (no heap allocation per byte of escaped text); a
/// raw run decodes in one call, so a long ASCII run in a Japanese, Chinese or Korean document
/// costs one append instead of one per byte. The destination's spare capacity is all
/// `decode_to_*` writes into, so a full buffer is drained and decoding continues.
fn decode_run(&mut self, bytes: &[u8]) {
// The decoder is taken out so the text it produced can be appended (which borrows the
// reader) before it goes back.
let Some(mut decoder) = self.mb_decoder.take() else { return };
let mut read = 0;
if bytes.len() <= 16 {
// A character is at most 4 UTF-8 bytes, and a byte may also replace a pending lead
// byte (3 more), so one escaped byte always fits the stack buffer.
let mut buf = [0u8; 16];
let (result, got, written, _) = decoder.decode_to_utf8(bytes, &mut buf, false);
read = got;
let full = matches!(result, encoding_rs::CoderResult::OutputFull);
if written > 0
&& let Ok(s) = std::str::from_utf8(&buf[..written])
{
self.text(s);
}
if !full {
self.mb_decoder = Some(decoder);
return;
}
}
let mut out = String::with_capacity(bytes.len().saturating_mul(2));
let mut rest = bytes.get(read..).unwrap_or_default();
loop {
let (result, got, _) = decoder.decode_to_string(rest, &mut out, false);
rest = rest.get(got..).unwrap_or_default();
if !matches!(result, encoding_rs::CoderResult::OutputFull) {
break;
}
out.reserve(16.max(rest.len().saturating_mul(2)));
}
self.mb_decoder = Some(decoder);
if !out.is_empty() {
self.text(&out);
}
}
/// A run of raw 8-bit text from the file (not `\'hh` escapes): a single-byte code page maps
/// each byte to a char; a multibyte one decodes the whole run in one pass, so a long ASCII run
/// in a Japanese, Chinese or Korean document costs one decode and one append instead of one
/// per byte.
///
/// The decoder is created without BOM handling: the code page comes from `\ansicpg`, `\cpg` or
/// `\fcharset` and must not change. With BOM sniffing, a run that starts `EF BB BF` in GBK,
/// Big5 or UHC (a valid character pair followed by a lead byte) would be dropped as a UTF-8
/// BOM and the rest decoded as UTF-8.
pub(super) fn run(&mut self, chunk: &[u8]) {
// The fallback bytes after `\uN` are skipped; the rest of the run is text like any other.
let skip = self.skip_chars.min(chunk.len());
self.skip_chars -= skip;
let chunk = chunk.get(skip..).unwrap_or_default();
if chunk.is_empty() {
return;
}
let cp = self.code_page();
let Some(encoding) = multibyte_encoding(cp) else {
self.finish_multibyte();
let mut out = String::with_capacity(chunk.len());
for b in chunk {
out.push(crate::codepage::code_page_byte(cp, *b));
}
if !out.is_empty() {
self.text(&out);
}
return;
};
if self.mb_cp != cp || self.mb_decoder.is_none() {
self.finish_multibyte();
self.mb_cp = cp;
self.mb_decoder = Some(encoding.new_decoder_without_bom_handling());
}
self.decode_run(chunk);
}
/// Ends the current multibyte run: the streaming decoder emits whatever it still holds (a lone
/// lead byte becomes U+FFFD, as Word writes it) and is dropped. A code page switch, a single-byte
/// run, a group end and the end of the document finish the run this way.
pub(super) fn finish_multibyte(&mut self) {
let Some(mut decoder) = self.mb_decoder.take() else { return };
let mut out = String::with_capacity(16);
let _ = decoder.decode_to_string(&[], &mut out, true);
if !out.is_empty() {
self.text(&out);
}
}
pub(super) fn finish_pict(&mut self) {
if self.pict_half.take().is_some() {
// Odd hex digit count: drop the dangling nibble.
}
let data = std::mem::take(&mut self.pict_hex);
if self.pict_kind == 0 || data.is_empty() {
return;
}
let sx = if self.pict_sx > 0.0 { self.pict_sx / 100.0 } else { 1.0 };
let sy = if self.pict_sy > 0.0 { self.pict_sy / 100.0 } else { 1.0 };
let w = self.pict_w.map(|v| v * sx);
let h = self.pict_h.map(|v| v * sy);
// The control word only says "a metafile": the bytes decide what it is (#4259).
let img = if matches!(self.pict_kind, 3 | 4) && craft_metafile::is_metafile(&data) {
// An EMF starts with its header record, type 1.
let ext = if data.get(..4) == Some(&[1, 0, 0, 0]) { "emf" } else { "wmf" };
// `\picw`/`\pich` are in the units of the `\wmetafileN` mapping mode; 0.01 mm for the
// isotropic and anisotropic modes and for EMF.
let unit = match (self.pict_kind, self.pict_mm) {
(3, 1) => 0.75, // MM_TEXT: pixels
(3, 2) => 72.0 / 254.0, // MM_LOMETRIC: 0.1 mm
(3, 4) => 0.72, // MM_LOENGLISH: 0.01 in
(3, 5) => 0.072, // MM_HIENGLISH: 0.001 in
(3, 6) => 0.05, // MM_TWIPS
_ => 72.0 / 2540.0, // MM_HIMETRIC, MM_ISOTROPIC, MM_ANISOTROPIC
};
let pt = |v: i32| (v > 0).then(|| v.min(1_000_000) as f32 * unit);
// `\picscalex`/`\picscaley` scale the natural size too, when no goal size is given.
let natural = pt(self.pict_nat.0).zip(pt(self.pict_nat.1)).map(|(a, b)| (a * sx, b * sy));
Some(model::sized_img(Arc::new(data), ext, natural, w, h, ""))
} else {
make_img(data, w, h, "")
};
if let Some(img) = img {
self.para.inlines.push(Inline::Image(img));
self.para_has = true;
}
}
pub(super) fn text(&mut self, s: &str) {
match self.st.dest {
Dest::Body => {
if self.st.hidden {
return;
}
let mut f = self.st.fmt.clone();
if s.contains([MARK_OPEN, MARK_CLOSE]) {
self.para.push_text(&s.replace([MARK_OPEN, MARK_CLOSE], "\u{FFFD}"), &f);
self.para_has = true;
return;
}
let r = self.st.rev;
if r.ins {
f.ins = Some(self.rev_mark(r.auth, r.dttm));
}
if r.del {
f.del = Some(self.rev_mark(r.authdel, r.dttmdel));
}
let c = &self.st.chg_c;
if let Some(words) = c.old.clone() {
let mark = self.rev_mark(c.auth, c.date);
let old = self.old_fmt(&words);
f.fmt_change = Some(Box::new(model::FmtChange { mark, old }));
}
self.para.push_text(s, &f);
self.para_has = true;
}
Dest::FontTbl => {
for c in s.chars() {
if c == ';' {
self.commit_font();
} else if self.font_name.len() <= model::MAX_FONT_NAME {
// One past the limit is enough for `commit_font` to drop the name.
self.font_name.push(c);
}
}
}
Dest::ColorTbl => {
for c in s.chars() {
if c == ';' {
let (r, g, b, set) = self.color_cur;
if self.colors.len() < 4096 {
self.colors.push(if set { Some(Rgb(r, g, b)) } else { None });
}
self.color_cur = (0, 0, 0, false);
}
}
}
Dest::StyleSheet => {
for c in s.chars() {
if c == ';' {
self.commit_style();
} else if self.style_name.len() < model::MAX_FONT_NAME {
self.style_name.push(c);
} else {
model::dropped(model::Dropped::StyleName);
}
}
}
Dest::Info(_) => self.info_text.push_str(s),
Dest::Atn(_) => {
if self.atn_text.len() < 256 {
self.atn_text.push_str(s);
}
}
Dest::FldInst => {
if let Some(f) = self.st.field.and_then(|i| self.fields.get_mut(i))
&& f.len() < 4096
{
f.push_str(s);
}
}
Dest::Pict => {
for b in s.bytes() {
let v = match b {
b'0'..=b'9' => b - b'0',
b'a'..=b'f' => b - b'a' + 10,
b'A'..=b'F' => b - b'A' + 10,
_ => continue,
};
match self.pict_half.take() {
Some(h) => {
if self.pict_hex.len() < MAX_PICT {
self.pict_hex.push((h << 4) | v);
}
}
None => self.pict_half = Some(v),
}
}
}
Dest::Bookmark => {
if let Some((_, n)) = &mut self.bkmk
&& n.len() < 256
{
n.push_str(s);
}
}
Dest::RevTbl => {
for c in s.chars() {
if c == ';' {
self.commit_author();
} else if self.rev_name.len() < 256 {
self.rev_name.push(c);
}
}
}
Dest::Skip | Dest::ListTable | Dest::ListOverride | Dest::OldProps(_) => {}
}
}
/// The formatting before a tracked character change: the current run's, with the
/// `\oldcprops` words applied (a `\plain` among them starts from none).
fn old_fmt(&mut self, words: &[(String, Option<i32>)]) -> Fmt {
let fmt = Fmt { link: None, ins: None, del: None, fmt_change: None, ..self.st.fmt.clone() };
let saved = std::mem::replace(&mut self.st, St { dest: Dest::OldProps(false), fmt, ..Default::default() });
for (w, p) in words {
self.word(w, *p, false, false);
}
let old = std::mem::replace(&mut self.st, saved).fmt;
Fmt { link: None, ..old }
}
pub(super) fn ch(&mut self, c: char) {
if self.skip_chars > 0 {
self.skip_chars -= 1;
return;
}
self.finish_multibyte();
// Only a literal `;` ends a font name: an escaped one (`\'3b`, `\u59?`) belongs to it.
if c == ';' && self.st.dest == Dest::FontTbl {
// Held to the limit as other characters are; one past it drops the name.
if self.font_name.len() <= model::MAX_FONT_NAME {
self.font_name.push(c);
}
return;
}
let mut buf = [0u8; 4];
self.text(c.encode_utf8(&mut buf));
}
}
+588
View File
@@ -0,0 +1,588 @@
use super::state::{CellDef, Dest, FmtRevSt, HeaderFooter, HfPages, MAX_OLD_WORDS, RevSt, St};
use super::styles::{StyleDef, is_char_format_word, is_format_word};
use super::{Align, Fmt, Reader, VAlign, model};
impl Reader {
pub(super) fn word(&mut self, w: &str, p: Option<i32>, starred: bool, first_in_group: bool) {
// Every control word ends a multibyte run: the code page it is read in may change here
// (`\f`, `\fcharset`, `\cpg`, `\ansicpg`, `\pard`), and `\par` must see the text before it.
self.finish_multibyte();
let on = p != Some(0);
let pv = p.unwrap_or(0);
if self.skip_destination_word(w, pv) {
return;
}
if w == "shppict" || self.start_header_footer_word(w) {
return;
}
if self.start_note_word(w) {
return;
}
let dest = self.destination_for_word(w);
if let Some(d) = dest {
match d {
Dest::Bookmark => {
self.bkmk = Some((self.depth(), String::new()));
}
Dest::Pict => {
self.pict_depth = Some(self.depth());
self.pict_hex.clear();
self.pict_half = None;
self.pict_kind = 0;
self.pict_w = None;
self.pict_h = None;
self.pict_sx = 0.0;
self.pict_sy = 0.0;
self.pict_nat = (0, 0);
self.pict_mm = 0;
}
Dest::FldInst if self.st.field.is_none() => self.start_field(),
// Old character formatting holds the properties that changed (Word) or, after a
// `\plain`, all of them; old paragraph formatting is complete from `\pard`.
Dest::OldProps(false) => {
self.st.old_words.clear();
self.st.old_rev = (None, None);
}
Dest::OldProps(true) => self.word("pard", None, false, false),
_ => {}
}
self.st.dest = d;
return;
}
if self.skip_ignorable_destination(w, starred, first_in_group) {
return;
}
if self.destination_word(w, p, on, pv) {
return;
}
match w {
"par" => self.end_para_mark(),
"sect" => self.end_section(),
"cell" => self.end_cell(),
"nestcell" => self.end_para(true),
"row" => self.end_row(),
"line" | "softline" => self.ch('\n'),
"tab" => self.ch('\t'),
"page" | "softpage" => self.ch('\u{000C}'),
"column" => self.ch('\u{000E}'),
"emdash" => self.ch('—'),
"endash" => self.ch('–'),
"bullet" => self.ch('•'),
"lquote" => self.ch('‘'),
"rquote" => self.ch('’'),
"ldblquote" => self.ch('“'),
"rdblquote" => self.ch('”'),
"emspace" => self.ch('\u{2003}'),
"enspace" => self.ch('\u{2002}'),
"qmspace" => self.ch('\u{2005}'),
"zwj" => self.ch('\u{200D}'),
"zwnj" => self.ch('\u{200C}'),
"zwbo" => self.ch('\u{200B}'),
"zwnbo" => self.ch('\u{2060}'),
"u" => {
let u = (pv as i64).rem_euclid(65_536) as u16;
if (0xD800..0xDC00).contains(&u) {
self.pending_high = Some(u);
} else {
let c = match self.pending_high.take() {
Some(h) if (0xDC00..0xE000).contains(&u) => char::decode_utf16([h, u]).next().and_then(Result::ok).unwrap_or('\u{FFFD}'),
_ => char::from_u32(u32::from(u)).unwrap_or('\u{FFFD}'),
};
self.ch(c);
}
self.skip_chars = self.st.uc;
}
"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();
self.st.chg_c = FmtRevSt::default();
self.st.hidden = false;
}
"pard" => {
let field = self.st.field;
// Revision marks are character formatting, which `\pard` leaves alone.
let (dest, fmt, rev, uc, hidden) = (self.st.dest, self.st.fmt.clone(), self.st.rev, self.st.uc, self.st.hidden);
let chg_c = std::mem::take(&mut self.st.chg_c);
self.st = St { dest, fmt, rev, chg_c, uc, hidden, field, ..Default::default() };
}
"revised" => self.st.rev.ins = on,
"deleted" => self.st.rev.del = on,
"revauth" => self.st.rev.auth = pv,
"revauthdel" => self.st.rev.authdel = pv,
// DTTMs are 32-bit patterns, written signed or unsigned.
"revdttm" => self.st.rev.dttm = pv as u32,
"revdttmdel" => self.st.rev.dttmdel = pv as u32,
"crauth" => self.st.chg_c.auth = pv,
"crdate" => self.st.chg_c.date = pv as u32,
"prauth" => self.st.chg_p.auth = pv,
"prdate" => self.st.chg_p.date = pv as u32,
"b" => self.st.fmt.bold = on,
"i" => self.st.fmt.italic = on,
"ul" | "uld" | "uldash" | "uldashd" | "uldashdd" | "uldb" | "ulth" | "ulw" | "ulwave" | "ulhwave" | "ulldash" | "ulthd" => {
self.st.fmt.underline = on
}
"ulnone" => self.st.fmt.underline = false,
"strike" | "striked" => self.st.fmt.strike = on,
"super" | "up" => {
self.st.fmt.sup = on;
self.st.fmt.sub = false;
}
"sub" | "dn" => {
self.st.fmt.sub = on;
self.st.fmt.sup = false;
}
"nosupersub" => {
self.st.fmt.sup = false;
self.st.fmt.sub = false;
}
"fs" => self.st.fmt.size = if pv > 0 { Some((pv as f32 / 2.0).clamp(1.0, 1638.0)) } else { None },
"ansicpg" => {
if let Some(cp) = u16::try_from(pv).ok().filter(|cp| crate::codepage::is_text_code_page(*cp)) {
self.ansicpg = cp;
}
}
// The Macintosh and IBM PC character sets: 8-bit text is Mac OS Roman, or DOS code
// page 437 (`\pc`) or 850 (`\pca`) (#1653).
"mac" if self.ansicpg == 0 => self.ansicpg = crate::codepage::MAC_ROMAN_CODE_PAGE,
"pc" if self.ansicpg == 0 => self.ansicpg = 437,
"pca" if self.ansicpg == 0 => self.ansicpg = 850,
"deff" => self.deff = pv,
"f" => {
self.st.cp = self.font_cps.get(&pv).copied();
let name = self.fonts.get(&pv).cloned();
match name {
Some(n) if model::is_mono(&n) => {
self.st.fmt.code = true;
self.st.fmt.font = None;
}
Some(n) => {
self.st.fmt.code = false;
self.st.fmt.font = Some(n);
}
None => {}
}
}
"cf" => self.st.fmt.color = self.colors.get(pv.max(0) as usize).copied().flatten(),
"highlight" | "chcbpat" | "cb" => self.st.fmt.background = self.colors.get(pv.max(0) as usize).copied().flatten(),
"v" => self.st.hidden = on,
"ql" => self.st.align = Some(Align::Left),
"qc" => self.st.align = Some(Align::Center),
"qr" => self.st.align = Some(Align::Right),
"qj" => self.st.align = Some(Align::Justify),
"qd" => self.st.align = Some(Align::Distribute),
"s" => self.st.style = pv,
"outlinelevel" => self.st.outline = Some(pv.clamp(0, 9) as u8),
"pagebb" => self.st.pagebb = on,
"intbl" => self.st.intbl = true,
"ls" => self.st.ls = Some(pv),
"ilvl" => self.st.ilvl = pv.clamp(0, 8) as u8,
"brdrb" | "brdrt" | "brdrl" | "brdrr" if self.cell_side.is_none() => {
if w == "brdrb" && self.st.dest == Dest::Body {
self.rule_pending = true;
}
self.st.border_side = w.chars().last();
}
"trowd" => {
self.defs.clear();
self.def_cur = CellDef::default();
self.row_header = false;
}
"trhdr" => self.row_header = true,
"clvmgf" => self.def_cur.vmerge = 1,
"clvmrg" => self.def_cur.vmerge = 2,
"clmgf" => self.def_cur.hmerge = 1,
"clmrg" => self.def_cur.hmerge = 2,
"clvertalt" => self.def_cur.valign = Some(VAlign::Top),
"clvertalc" => self.def_cur.valign = Some(VAlign::Center),
"clvertalb" => self.def_cur.valign = Some(VAlign::Bottom),
"clcbpat" => self.def_cur.shading = self.colors.get(pv.max(0) as usize).copied().flatten(),
"sb" => self.st.sb = Some(pv),
"sa" => self.st.sa = Some(pv),
"sl" => self.st.sl = pv,
"slmult" => self.st.slmult = on,
"li" | "lin" => self.st.li = Some(pv),
"ri" | "rin" => self.st.ri = Some(pv),
"fi" => self.st.fi = Some(pv),
"clbrdrt" => self.cell_side = Some('t'),
"clbrdrl" => self.cell_side = Some('l'),
"clbrdrb" => self.cell_side = Some('b'),
"clbrdrr" => self.cell_side = Some('r'),
"brdrs" | "brdrth" | "brdrsh" | "brdrdb" | "brdrdot" | "brdrdash" | "brdrdashsm" | "brdrdashd" | "brdrdashdd" | "brdrhair"
| "brdrtriple" | "brdrthtnsg" | "brdrtnthsg" | "brdrtnthtnsg" | "brdrthtnmg" | "brdrtnthmg" | "brdrtnthtnmg" | "brdrthtnlg"
| "brdrtnthlg" | "brdrtnthtnlg" | "brdrwavy" | "brdrwavydb" | "brdrnone" | "brdrnil" | "brdrtbl"
if self.cell_side.is_some() || self.st.border_side.is_some() =>
{
use craft_wordmodel::props::{Border, BorderStyle};
let style = match w {
"brdrnone" | "brdrnil" | "brdrtbl" => BorderStyle::None,
"brdrth" => BorderStyle::Thick,
"brdrdb" | "brdrwavydb" => BorderStyle::Double,
"brdrtnthsg" => BorderStyle::ThinThickSmallGap,
"brdrthtnsg" => BorderStyle::ThickThinSmallGap,
"brdrtnthtnsg" => BorderStyle::ThinThickThinSmallGap,
"brdrtnthmg" => BorderStyle::ThinThickMediumGap,
"brdrthtnmg" => BorderStyle::ThickThinMediumGap,
"brdrtnthtnmg" => BorderStyle::ThinThickThinMediumGap,
"brdrtnthlg" => BorderStyle::ThinThickLargeGap,
"brdrthtnlg" => BorderStyle::ThickThinLargeGap,
"brdrtnthtnlg" => BorderStyle::ThinThickThinLargeGap,
"brdrdot" => BorderStyle::Dotted,
"brdrdash" | "brdrdashsm" => BorderStyle::Dashed,
"brdrdashd" | "brdrdashdd" => BorderStyle::DotDash,
"brdrtriple" => BorderStyle::Triple,
"brdrwavy" => BorderStyle::Wave,
_ => BorderStyle::Single,
};
let b = (style != BorderStyle::None).then_some(Border { style, ..Border::single(0.5) });
if let Some(slot) = self.cell_border_slot() {
*slot = b;
}
}
"brdrw" if self.cell_side.is_some() || self.st.border_side.is_some() => {
let width = (pv as f32 / 20.0).clamp(0.25, 6.0);
if let Some(Some(b)) = self.cell_border_slot() {
b.width = width;
}
}
"brdrcf" if self.cell_side.is_some() || self.st.border_side.is_some() => {
let color = self.colors.get(pv.max(0) as usize).copied().flatten();
if let Some(Some(b)) = self.cell_border_slot() {
b.color = color;
}
}
"paperw" => self.page.w = Some(pv),
"paperh" => self.page.h = Some(pv),
"margl" => self.page.left = Some(pv),
"margr" => self.page.right = Some(pv),
"margt" => self.page.top = Some(pv),
"margb" => self.page.bottom = Some(pv),
"landscape" => self.page.landscape = on,
_ if self.section_word(w, p) => {}
"cellx" => {
self.cell_side = None;
let mut d = std::mem::take(&mut self.def_cur);
d.right = pv;
if self.defs.len() < 63 {
self.defs.push(d);
}
}
"field" => self.start_field(),
"fldrslt" => {
let link = self.st.field.and_then(|i| self.fields.get(i)).and_then(|s| hyperlink(s));
if link.is_some() {
self.st.fmt.link = link.as_deref().and_then(model::kept_link);
}
}
_ => {}
}
}
fn destination_for_word(&mut self, w: &str) -> Option<Dest> {
match w {
"fonttbl" => Some(Dest::FontTbl),
"colortbl" => Some(Dest::ColorTbl),
"stylesheet" => Some(Dest::StyleSheet),
"info" => Some(Dest::Skip),
"title" if self.st.dest == Dest::Skip && self.in_info() => Some(Dest::Info(1)),
"author" if self.in_info() => Some(Dest::Info(2)),
"subject" if self.in_info() => Some(Dest::Info(3)),
"keywords" if self.in_info() => Some(Dest::Info(4)),
"doccomm" if self.in_info() => Some(Dest::Info(5)),
"listtable" => Some(Dest::ListTable),
"revtbl" => Some(Dest::RevTbl),
"oldcprops" => Some(Dest::OldProps(false)),
"oldpprops" => Some(Dest::OldProps(true)),
"listoverridetable" => Some(Dest::ListOverride),
"fldinst" => Some(Dest::FldInst),
// Transparent wrapper: read the nested pict without changing the destination.
"pict" => Some(Dest::Pict),
"bkmkstart" => Some(Dest::Bookmark),
"atrfstart" | "atrfend" | "atnid" | "atnauthor" | "atndate" | "atntime" | "atnref"
if self.marks_on
&& self.hf.is_none()
&& self.st.dest == Dest::Body
&& self.sub.as_ref().is_none_or(|s| s.comment && !w.starts_with("atrf")) =>
{
self.atn_text.clear();
Some(Dest::Atn(match w {
"atrfstart" => 1,
"atrfend" => 2,
"atnid" => 3,
"atnauthor" => 4,
"atndate" | "atntime" => 5,
_ => 6,
}))
}
"header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "pntext"
| "listtext" | "object" | "nonshppict" | "xe" | "tc" | "txe" | "private" | "bkmkend" | "atnid" | "atnauthor" | "comment" | "shpinst"
| "do" | "operator" | "company" | "manager" | "category" | "hlinkbase" | "revtime" | "printim" | "buptim" | "version" | "nofpages"
| "nofwords" | "nofchars" | "edmins" | "vern" | "themedata" | "colorschememapping" | "latentstyles" | "datastore" | "xmlnstbl"
| "rsidtbl" | "generator" | "mmathPr" | "pgdsctbl" | "filetbl" | "listpicture" | "userprops" | "docvar" | "objdata" | "fontemb"
| "fontfile" | "panose" | "falt" | "mhtmltag" | "htmltag" | "pnseclvl" | "background" | "sp" | "pn" | "pgptbl" => Some(Dest::Skip),
"creatim" if self.in_info() => {
self.in_creatim = true;
Some(Dest::Skip)
}
_ => None,
}
}
/// The words that open a note or comment story of their own (`\footnote`, `\annotation`) and
/// the two inside a note: `\ftnalt` (an endnote) and `\chftn` (the note's own number).
fn start_note_word(&mut self, word: &str) -> bool {
if self.st.dest == Dest::Body {
match word {
"ftnalt" => {
if let Some(sub) = self.sub.as_mut() {
sub.endnote = true;
}
return true;
}
"chftn" if self.sub.as_ref().is_some_and(|s| !s.comment) => {
let f = self.st.fmt.clone();
self.push_mark('S', 0, f);
return true;
}
_ => {}
}
}
if !matches!(word, "footnote" | "annotation") || !self.marks_on || self.hf.is_some() || self.sub.is_some() || self.st.dest != Dest::Body {
return false;
}
self.start_sub(word == "annotation");
true
}
fn start_header_footer_word(&mut self, word: &str) -> bool {
if !matches!(word, "header" | "footer" | "headerl" | "headerr" | "headerf" | "footerl" | "footerr" | "footerf")
|| self.hf.is_some()
|| self.st.dest != Dest::Body
|| self.st.intbl
{
return false;
}
let footer = word.starts_with("footer");
let pages = match word.strip_prefix(if footer { "footer" } else { "header" }) {
Some("f") => HfPages::First,
Some("l") => HfPages::Even,
_ => HfPages::Default,
};
self.start_header_footer(HeaderFooter { footer, pages });
true
}
fn skip_ignorable_destination(&mut self, word: &str, starred: bool, first_in_group: bool) -> bool {
if !starred || !first_in_group || matches!(word, "fldinst" | "bkmkstart") {
return false;
}
self.st.dest = Dest::Skip;
true
}
fn destination_word(&mut self, word: &str, param: Option<i32>, on: bool, value: i32) -> bool {
match self.st.dest {
Dest::Skip => {
if self.in_creatim {
self.record_created_part(word, value);
}
true
}
Dest::FontTbl if !matches!(word, "u" | "uc") => {
self.font_table_word(word, value);
true
}
Dest::FontTbl => false,
Dest::ColorTbl => {
let color = value.clamp(0, 255) as u8;
match word {
"red" => (self.color_cur.0, self.color_cur.3) = (color, true),
"green" => (self.color_cur.1, self.color_cur.3) = (color, true),
"blue" => (self.color_cur.2, self.color_cur.3) = (color, true),
_ => {}
}
true
}
// Style names take Unicode escapes (with fallback skipping) like the body.
Dest::StyleSheet if !matches!(word, "u" | "uc") => {
let def = &mut self.style_def_cur;
match word {
"s" | "cs" | "ds" | "ts" => {
self.style_cur = if word == "s" { value } else { -1 - value.saturating_abs() };
self.style_name.clear();
*def = StyleDef::default();
}
"sbasedon" => def.based_on = Some(value),
"b" => def.bold = Some(on),
"i" => def.italic = Some(on),
"fs" => def.size = (value > 0).then(|| (value as f32 / 2.0).clamp(1.0, 1638.0)),
"f" => def.font = Some(value),
_ => {}
}
true
}
Dest::StyleSheet => false,
Dest::ListTable => {
self.list_table_word(word, value);
true
}
Dest::ListOverride => {
match word {
"listid" => self.ov_list = value,
"ls" => {
self.ov_ls = value;
if self.overrides.len() < 4096 {
self.overrides.insert(value, self.ov_list);
}
}
_ => {}
}
true
}
Dest::RevTbl if !matches!(word, "u" | "uc") => true,
Dest::RevTbl => false,
Dest::Pict => {
self.picture_word(word, value);
true
}
Dest::Atn(kind) => {
if kind == 5 {
self.record_atn_part(word, value);
}
!matches!(word, "u" | "uc")
}
Dest::Info(_) | Dest::FldInst | Dest::Bookmark if !matches!(word, "u" | "uc") => true,
Dest::Info(_) | Dest::FldInst | Dest::Bookmark => false,
Dest::OldProps(false) if matches!(word, "crauth" | "crdate") => {
if word == "crauth" {
self.st.old_rev.0 = Some(value);
} else {
self.st.old_rev.1 = Some(value as u32);
}
true
}
Dest::OldProps(_) if !is_format_word(word) => true,
Dest::OldProps(false) if word == "pard" => true,
Dest::OldProps(_) if self.cell_side.is_some() && word.starts_with("brdr") => true,
Dest::OldProps(false) => {
if is_char_format_word(word) && self.st.old_words.len() < MAX_OLD_WORDS {
self.st.old_words.push((word.to_string(), param));
}
false
}
Dest::OldProps(true) | Dest::Body => false,
}
}
fn record_atn_part(&mut self, word: &str, value: i32) {
let v = value.clamp(0, 9999) as u32;
let c = self.atn_parts.get_or_insert((0, 1, 1, 0, 0));
match word {
"yr" => c.0 = v,
"mo" => c.1 = v,
"dy" => c.2 = v,
"hr" => c.3 = v,
"min" => c.4 = v,
_ => {}
}
}
fn font_table_word(&mut self, word: &str, value: i32) {
match word {
"f" => {
self.font_cur = value;
self.font_name.clear();
self.font_cp_cur = None;
}
"fcharset" => self.font_cp_cur = crate::codepage::charset_code_page(value),
"cpg" => {
if let Some(cp) = u16::try_from(value).ok().filter(|cp| crate::codepage::is_text_code_page(*cp)) {
self.font_cp_cur = Some(cp);
}
}
_ => {}
}
}
fn list_table_word(&mut self, word: &str, value: i32) {
match word {
"list" => {
self.list_cur.clear();
self.list_cur_id = 0;
}
"listlevel" if self.list_cur.len() < 9 => self.list_cur.push(true),
"levelnfc" | "levelnfcn" => {
if let Some(level) = self.list_cur.last_mut() {
*level = !matches!(value, 23 | 255);
}
}
"listid" => {
self.list_cur_id = value;
if self.lists.len() < 4096 {
self.lists.insert(value, self.list_cur.clone());
}
}
_ => {}
}
}
fn picture_word(&mut self, word: &str, value: i32) {
match word {
"pngblip" => self.pict_kind = 1,
"jpegblip" => self.pict_kind = 2,
// Windows and Enhanced Metafiles (#4259): drawn by the metafile renderer.
"wmetafile" => {
self.pict_kind = 3;
self.pict_mm = value;
}
"emfblip" => self.pict_kind = 4,
"picw" => self.pict_nat.0 = value,
"pich" => self.pict_nat.1 = value,
"picwgoal" => self.pict_w = Some(value.clamp(1, 31_680) as f32 / 20.0),
"pichgoal" => self.pict_h = Some(value.clamp(1, 31_680) as f32 / 20.0),
"picscalex" => self.pict_sx = value.clamp(1, 1000) as f32,
"picscaley" => self.pict_sy = value.clamp(1, 1000) as f32,
_ => {}
}
}
fn skip_destination_word(&mut self, word: &str, value: i32) -> bool {
let kept_info = self.in_info() && matches!(word, "title" | "author" | "subject" | "keywords" | "doccomm" | "creatim");
if self.st.dest != Dest::Skip || kept_info {
return false;
}
if self.in_creatim {
self.record_created_part(word, value);
}
true
}
fn record_created_part(&mut self, word: &str, value: i32) {
let value = value.clamp(0, 9999) as u32;
let created = self.created.get_or_insert((0, 1, 1, 0, 0));
match word {
"yr" => created.0 = value,
"mo" => created.1 = value,
"dy" => created.2 = value,
"hr" => created.3 = value,
"min" => created.4 = value,
_ => {}
}
}
fn start_field(&mut self) {
if self.fields.len() < 100_000 {
self.fields.push(String::new());
self.st.field = Some(self.fields.len() - 1);
}
}
fn in_info(&self) -> bool {
self.info_depth.is_some_and(|d| self.depth() > d)
}
}
/// The target of a `HYPERLINK` field instruction; `%22` is a quote, as the writer stores one.
pub(super) fn hyperlink(instr: &str) -> Option<String> {
craft_wordmodel::link::hyperlink_target(instr).map(|t| t.replace("%22", "\""))
}
+2
View File
@@ -31,6 +31,8 @@ struct ReadmeExamples;
pub mod codepage;
mod date;
#[cfg(feature = "doc")]
pub mod doc;
mod lex;
mod model;
#[cfg(feature = "outlook")]