mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
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.
973 lines
40 KiB
Rust
973 lines
40 KiB
Rust
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)
|
|
}
|