mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
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:
1 parent
c2b7dac5a2
commit
7fe902292f
12 files changed
+5408
-4
No files matched your search
@@ -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
@@ -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.
|
||||
@@ -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;
|
||||
@@ -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(§_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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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", "\""))
|
||||
}
|
||||
@@ -31,6 +31,8 @@ struct ReadmeExamples;
|
||||
|
||||
pub mod codepage;
|
||||
mod date;
|
||||
#[cfg(feature = "doc")]
|
||||
pub mod doc;
|
||||
mod lex;
|
||||
mod model;
|
||||
#[cfg(feature = "outlook")]
|
||||
|
||||
Reference in new issue
Block a user