mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
Place: RTF files place as area type with their formatting
File > Place read text files only as plain `.txt`, and an RTF file
could not be placed. The reference app lists RTF among the formats it
places as text, keeping the file's font and style settings.
File > Place now reads RTF files, written from the Rich Text Format
Specification 1.9.1, and sets their text as area type the way a text
file is placed. Each run keeps its font from the font table, its size,
bold and italic, underline, strikethrough, all caps, small caps,
superscript or subscript (in Document Setup's proportions), baseline
shift and its color from the color table. Each paragraph keeps its
alignment, left, right and first-line indents, space before and after,
and direction. Table cells are separated by tabs and each table row is
a paragraph. Unicode characters, Windows-1252 and Mac Roman bytes and
the special-character control words are read, and of a \upr group's
ANSI and Unicode text, the Unicode text is read. Hidden text, pictures,
objects, headers, footers, notes, field instructions and other
destinations are left out, and unknown control words are skipped.
A file is read as RTF when it starts with {\rtf, whatever its name,
except a .txt file, which stays plain text. RTF files share the 4 MiB
limit of text files. Groups nested more than 512 deep share the
formatting of the deepest one read, the font and color tables keep at
most 4,096 entries, and a file with more than 100,000 paragraphs or
runs is refused. The Place dialog lists RTF files, and file.place,
file.place.info and file.place.queue report the format `rtf`.
The tests in tests_textimport.rs fail without the change, and
text_import_fuzz.rs places random RTF token streams.
Docs: docs/file-format-parity.md, docs/mcp.md,
docs/target-app-parity.md.
This commit is contained in:
@@ -251,7 +251,7 @@ pub(crate) fn script_params(p: &Value, setup: &DocSetup, cmd: &str) -> Result<(O
|
||||
}
|
||||
|
||||
/// A superscript or subscript in `setup`'s proportions.
|
||||
fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
|
||||
pub(crate) fn position_in(setup: &DocSetup, p: CharPosition) -> CharPosition {
|
||||
match p {
|
||||
CharPosition::Normal => CharPosition::Normal,
|
||||
CharPosition::Superscript(_) => CharPosition::Superscript(setup.superscript),
|
||||
|
||||
@@ -707,7 +707,10 @@ pub fn export_extensions() -> Vec<&'static str> {
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`] and [`TEXT_EXTS`].
|
||||
/// RTF files: File → Place sets them as area type with their formatting.
|
||||
pub const RTF_EXTS: &[&str] = &["rtf"];
|
||||
|
||||
/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`] and [`RTF_EXTS`].
|
||||
pub const PLACE_EXTS: &[&str] = &[
|
||||
"vectorcraft",
|
||||
"drawcraft",
|
||||
@@ -737,6 +740,7 @@ pub const PLACE_EXTS: &[&str] = &[
|
||||
"afdesign",
|
||||
"afpub",
|
||||
"txt",
|
||||
"rtf",
|
||||
];
|
||||
|
||||
/// One dialog filter per readable format.
|
||||
@@ -760,11 +764,13 @@ pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static
|
||||
.chain(std::iter::once(("Plug-ins", super::plugin::EXTS)))
|
||||
}
|
||||
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text.
|
||||
/// File → Place dialog filters: "All placeable files", then one per placeable format, then text
|
||||
/// and RTF.
|
||||
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
|
||||
std::iter::once(("All placeable files", PLACE_EXTS))
|
||||
.chain(format_filters().filter(|(_, exts)| exts.iter().all(|ext| PLACE_EXTS.contains(ext))))
|
||||
.chain(std::iter::once(("Text", TEXT_EXTS)))
|
||||
.chain(std::iter::once(("RTF", RTF_EXTS)))
|
||||
}
|
||||
|
||||
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
//! (`crop: "bounding"`).
|
||||
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
|
||||
//! [`text`]).
|
||||
//! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]).
|
||||
//! - A VectorCraft document placed from a file with Link (the default) becomes a placed document:
|
||||
//! one locked object showing the artboard `page`, linked to the file and read again when it
|
||||
//! changes ([`document`]); without Link, an editable copy of its art.
|
||||
@@ -27,6 +28,7 @@
|
||||
|
||||
pub(crate) mod adopt;
|
||||
pub(crate) mod document;
|
||||
mod rtf;
|
||||
mod text;
|
||||
|
||||
use std::sync::Arc;
|
||||
@@ -96,8 +98,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// A file's art at its natural size, before it joins a document.
|
||||
enum Art {
|
||||
Image(RasterImage),
|
||||
/// A text file's text, placed as area type filling the natural box.
|
||||
Text(String),
|
||||
/// A text or RTF file's text, placed as area type filling the natural box.
|
||||
Text(text::Story),
|
||||
/// A VectorCraft document placed linked: the file read, the link, and whether the art's
|
||||
/// bounds (not the artboard) are shown.
|
||||
Document(document::Source, LinkInfo, bool),
|
||||
@@ -142,7 +144,16 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>) -> Result<Loaded> {
|
||||
if text.trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
}
|
||||
return Ok(Loaded { name, format: &text::FORMAT, art: Art::Text(text), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
let art = Art::Text(text::Story::plain(text));
|
||||
return Ok(Loaded { name, format: &text::FORMAT, art, natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
}
|
||||
// An RTF file is recognized by its content, whatever its name says (a .txt file is plain text).
|
||||
if rtf::is_rtf(&src.bytes) {
|
||||
let story = rtf::import(&src.bytes, cmd)?;
|
||||
if story.plain_text().trim().is_empty() {
|
||||
return Err(bad(cmd, format!("`{name}` has no text to place")));
|
||||
}
|
||||
return Ok(Loaded { name, format: &rtf::FORMAT, art: Art::Text(story), natural: TEXT_FRAME, warnings: vec![], link: None, board: None });
|
||||
}
|
||||
let format = fileio::detect(src.name, &src.bytes)
|
||||
.ok_or_else(|| bad(cmd, format!("can't place `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
@@ -298,7 +309,7 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
|
||||
}
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
|
||||
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural, &d.setup)))),
|
||||
Art::Document(s, link, bounding) => document::node(d, &s, link, bounding),
|
||||
Art::Vector { src, mut nodes, clip } => {
|
||||
adopt::adopt(d, &src, &mut nodes);
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
//! Placing an RTF file: its text set as area type with the character and paragraph formatting
|
||||
//! VectorCraft has, read as the Rich Text Format (RTF) Specification 1.9.1 describes the format
|
||||
//! (<https://www.microsoft.com/en-us/download/details.aspx?id=10725>).
|
||||
//!
|
||||
//! - Text: `\par`, `\line` and `\sect` start a new paragraph, and so does `\row` (the end of a
|
||||
//! table row, whose cells, ended by `\cell` or `\nestcell`, are separated by tabs). `\tab` is a
|
||||
//! tab, `\uN` a Unicode character (the `\ucN` fallback characters after it are skipped), and
|
||||
//! `\'hh` a byte in Windows-1252, or in Mac Roman after `\mac` or `\ansicpg10000`. Of a `\upr`
|
||||
//! group's ANSI and Unicode (`\*\ud`) groups, the Unicode one is read. The control
|
||||
//! symbols `\~`, `\-` and `\_` and the control words for quotes, dashes, bullets and special
|
||||
//! spaces are their characters.
|
||||
//! - Character formatting, reset by `\plain`: the font table's font (`\f`, else `\deff`), the size
|
||||
//! (`\fs`), bold, italic, underline (every underline style), strikethrough, all caps, small caps,
|
||||
//! superscript and subscript, baseline shift (`\up`, `\dn`) and the color table's color (`\cf`).
|
||||
//! - Paragraph formatting, reset by `\pard`: alignment (`\ql`, `\qc`, `\qr`, `\qj`, `\qd`), left,
|
||||
//! right and first-line indents, space before and after, and direction (`\rtlpar`, `\ltrpar`).
|
||||
//!
|
||||
//! Other control words are skipped, and so are hidden text (`\v`) and the groups of destinations
|
||||
//! that are not the document's text: every `\*` destination, the style sheet, document
|
||||
//! information, pictures, objects, shapes, headers, footers, notes, note separators, the text for
|
||||
//! readers without nested tables (`\nonesttables`) and field instructions (a field's result is
|
||||
//! read).
|
||||
//! Groups nested deeper than [`MAX_GROUPS`] share the formatting of the deepest one read, and a
|
||||
//! file with more than [`MAX_PARAGRAPHS`] paragraphs or [`MAX_RUNS`] runs of one formatting is
|
||||
//! refused.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun};
|
||||
|
||||
use super::text::{self, Story};
|
||||
use super::*;
|
||||
|
||||
/// RTF files as `file.place` reports them (not a format `document.open` reads).
|
||||
pub(super) const FORMAT: fileio::Format = fileio::Format {
|
||||
id: "rtf",
|
||||
label: "RTF",
|
||||
extensions: fileio::RTF_EXTS,
|
||||
mime: "application/rtf",
|
||||
read: true,
|
||||
write: false,
|
||||
raster: false,
|
||||
options: &[],
|
||||
};
|
||||
|
||||
/// The deepest group nesting read; deeper groups share the formatting of the deepest one read.
|
||||
const MAX_GROUPS: usize = 512;
|
||||
/// The most fonts and colors the font and color tables hold.
|
||||
const MAX_TABLE: usize = 4096;
|
||||
/// The reader adds no more characters to a font name once it holds this many bytes.
|
||||
const MAX_NAME: usize = 256;
|
||||
/// The most paragraphs placed type takes from one RTF file.
|
||||
const MAX_PARAGRAPHS: usize = 100_000;
|
||||
/// The most runs of one character formatting placed type takes from one RTF file.
|
||||
const MAX_RUNS: usize = 100_000;
|
||||
|
||||
/// Does `bytes` hold an RTF file (`{\rtf` after any white space)?
|
||||
pub(super) fn is_rtf(bytes: &[u8]) -> bool {
|
||||
let start = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(bytes.len());
|
||||
bytes.get(start..).is_some_and(|b| b.starts_with(b"{\\rtf"))
|
||||
}
|
||||
|
||||
/// The text of an RTF file's `bytes` with its character and paragraph formatting.
|
||||
pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<Story> {
|
||||
if bytes.len() > text::MAX_TEXT {
|
||||
return Err(bad(cmd, format!("the RTF file is too large to place as type (at most {} MB)", text::MAX_TEXT >> 20)));
|
||||
}
|
||||
if !is_rtf(bytes) {
|
||||
return Err(bad(cmd, "not an RTF file: it doesn't start with {\\rtf"));
|
||||
}
|
||||
let mut r = Reader::default();
|
||||
let mut i = bytes.iter().position(|b| !b.is_ascii_whitespace()).unwrap_or(0);
|
||||
let mut starred = false;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if r.full {
|
||||
break;
|
||||
}
|
||||
i += 1;
|
||||
match b {
|
||||
b'{' => {
|
||||
starred = false;
|
||||
r.open();
|
||||
}
|
||||
b'}' => {
|
||||
starred = false;
|
||||
// The end of the outermost group is the end of the document.
|
||||
if !r.close() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
b'\\' => {
|
||||
let Some(&next) = bytes.get(i) else { break };
|
||||
if next.is_ascii_alphabetic() {
|
||||
let (word, n, end) = control_word(bytes, i);
|
||||
i = end;
|
||||
if word == "bin" {
|
||||
// Binary data: never text, and one character of a `\uN` fallback.
|
||||
i = i.saturating_add(usize::try_from(n.unwrap_or(0)).unwrap_or(0)).min(bytes.len());
|
||||
r.skip = r.skip.saturating_sub(1);
|
||||
continue;
|
||||
}
|
||||
r.word(word, n, std::mem::take(&mut starred));
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
match next {
|
||||
b'*' => starred = true,
|
||||
b'\'' => {
|
||||
if let Some(v) = bytes.get(i..i + 2).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok()) {
|
||||
i += 2;
|
||||
r.byte(v);
|
||||
}
|
||||
}
|
||||
b'\r' | b'\n' => r.word("par", None, false),
|
||||
b'\t' => r.put('\t'),
|
||||
b'~' => r.put('\u{A0}'),
|
||||
b'-' => r.put('\u{AD}'),
|
||||
b'_' => r.put('\u{2011}'),
|
||||
b'\\' | b'{' | b'}' => r.put(char::from(next)),
|
||||
// Other control symbols (index entries, formula characters) count as one
|
||||
// character of a `\uN` fallback.
|
||||
_ => r.skip = r.skip.saturating_sub(1),
|
||||
}
|
||||
}
|
||||
// Line breaks in the file are not text.
|
||||
b'\r' | b'\n' => {}
|
||||
b'\t' => r.put('\t'),
|
||||
0..=0x1F => {}
|
||||
_ => r.byte(b),
|
||||
}
|
||||
}
|
||||
r.finish(cmd)
|
||||
}
|
||||
|
||||
/// The control word whose letters start at `bytes[at]`: its name (at most 32 letters), its numeric
|
||||
/// parameter, and where the text after it starts (a space after it belongs to the word).
|
||||
fn control_word(bytes: &[u8], at: usize) -> (&str, Option<i32>, usize) {
|
||||
let rest = bytes.get(at..).unwrap_or_default();
|
||||
let letters = rest.iter().take(32).take_while(|b| b.is_ascii_alphabetic()).count();
|
||||
let word = rest.get(..letters).and_then(|w| std::str::from_utf8(w).ok()).unwrap_or_default();
|
||||
let mut end = at + letters;
|
||||
let negative = bytes.get(end) == Some(&b'-') && bytes.get(end + 1).is_some_and(u8::is_ascii_digit);
|
||||
if negative {
|
||||
end += 1;
|
||||
}
|
||||
let digits = bytes.get(end..).unwrap_or_default().iter().take(10).take_while(|b| b.is_ascii_digit()).count();
|
||||
let n = bytes
|
||||
.get(end..end + digits)
|
||||
.filter(|d| !d.is_empty())
|
||||
.and_then(|d| std::str::from_utf8(d).ok())
|
||||
.and_then(|d| d.parse::<i64>().ok())
|
||||
.map(|v| (if negative { -v } else { v }).clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32);
|
||||
end += digits;
|
||||
if bytes.get(end) == Some(&b' ') {
|
||||
end += 1;
|
||||
}
|
||||
(word, n, end)
|
||||
}
|
||||
|
||||
/// What the characters of the current group are part of.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Dest {
|
||||
/// The document's text.
|
||||
Body,
|
||||
/// The font table: font names.
|
||||
Fonts,
|
||||
/// The color table: `\red`, `\green` and `\blue` entries ended by `;`.
|
||||
Colors,
|
||||
/// Anything else: skipped.
|
||||
Skip,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
enum Script {
|
||||
#[default]
|
||||
Normal,
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// Character formatting (what `\plain` resets).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Chars {
|
||||
/// The font table's font number; None: the default font (`\deff`).
|
||||
font: Option<i32>,
|
||||
/// Size in half points.
|
||||
half_points: i32,
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
underline: bool,
|
||||
strike: bool,
|
||||
caps: bool,
|
||||
small_caps: bool,
|
||||
script: Script,
|
||||
/// Baseline shift in half points (up positive).
|
||||
shift: i32,
|
||||
/// The color table's color number; a number without an entry, or whose entry has no values, gives the default color.
|
||||
color: i32,
|
||||
/// Hidden text (`\v`): not placed.
|
||||
hidden: bool,
|
||||
}
|
||||
|
||||
impl Default for Chars {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
font: None,
|
||||
half_points: 24,
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
strike: false,
|
||||
caps: false,
|
||||
small_caps: false,
|
||||
script: Script::Normal,
|
||||
shift: 0,
|
||||
color: 0,
|
||||
hidden: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paragraph formatting (what `\pard` resets); lengths in twips.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Para {
|
||||
justify: Justify,
|
||||
left: i32,
|
||||
right: i32,
|
||||
first: i32,
|
||||
before: i32,
|
||||
after: i32,
|
||||
rtl: Option<bool>,
|
||||
}
|
||||
|
||||
/// The state a group keeps: it is restored when the group ends.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Group {
|
||||
dest: Dest,
|
||||
chars: Chars,
|
||||
para: Para,
|
||||
/// How many fallback characters follow each `\uN`.
|
||||
uc: usize,
|
||||
/// A `\upr` group: of its two groups, the ANSI one is skipped and the `\*\ud` one is read.
|
||||
upr: bool,
|
||||
}
|
||||
|
||||
impl Default for Group {
|
||||
fn default() -> Self {
|
||||
Self { dest: Dest::Body, chars: Chars::default(), para: Para::default(), uc: 1, upr: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Reader {
|
||||
group: Group,
|
||||
stack: Vec<Group>,
|
||||
/// Groups opened past [`MAX_GROUPS`] and not yet closed.
|
||||
overflow: usize,
|
||||
/// `\'hh` bytes are Mac Roman (else Windows-1252).
|
||||
mac: bool,
|
||||
default_font: i32,
|
||||
fonts: HashMap<i32, String>,
|
||||
/// The font the font table is naming.
|
||||
font: i32,
|
||||
font_name: String,
|
||||
colors: Vec<Option<[u8; 3]>>,
|
||||
rgb: [Option<u8>; 3],
|
||||
/// Fallback characters of a `\uN` still to skip.
|
||||
skip: usize,
|
||||
/// The high surrogate of a character written as two `\uN`.
|
||||
high: Option<u16>,
|
||||
/// A cell just ended: a tab goes before the next character of its row.
|
||||
cell_tab: bool,
|
||||
/// The text, in runs of one character formatting; paragraphs end with `\n`.
|
||||
spans: Vec<(String, Chars)>,
|
||||
/// The formatting of each paragraph that has ended.
|
||||
paras: Vec<Para>,
|
||||
/// The formatting of the paragraph being read, as at its last character or paragraph
|
||||
/// formatting word.
|
||||
last: Para,
|
||||
/// Past [`MAX_PARAGRAPHS`] or [`MAX_RUNS`].
|
||||
full: bool,
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn open(&mut self) {
|
||||
self.skip = 0;
|
||||
if self.stack.len() >= MAX_GROUPS {
|
||||
self.overflow += 1;
|
||||
} else {
|
||||
self.stack.push(self.group);
|
||||
if std::mem::take(&mut self.group.upr) {
|
||||
self.group.dest = Dest::Skip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ends a group; false when it was the outermost one.
|
||||
fn close(&mut self) -> bool {
|
||||
self.skip = 0;
|
||||
if self.overflow > 0 {
|
||||
self.overflow -= 1;
|
||||
return true;
|
||||
}
|
||||
if self.group.dest == Dest::Fonts {
|
||||
self.commit_font();
|
||||
}
|
||||
match self.stack.pop() {
|
||||
Some(g) => {
|
||||
self.group = g;
|
||||
!self.stack.is_empty()
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_font(&mut self) {
|
||||
let name = std::mem::take(&mut self.font_name);
|
||||
let name = name.trim();
|
||||
if !name.is_empty() && self.fonts.len() < MAX_TABLE {
|
||||
self.fonts.insert(self.font, name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_color(&mut self) {
|
||||
let rgb = std::mem::take(&mut self.rgb);
|
||||
if self.colors.len() < MAX_TABLE {
|
||||
self.colors.push(rgb.iter().any(Option::is_some).then(|| rgb.map(|v| v.unwrap_or(0))));
|
||||
}
|
||||
}
|
||||
|
||||
/// Byte `b` of the file's character set.
|
||||
fn byte(&mut self, b: u8) {
|
||||
self.put(if self.mac { text::mac_roman(b) } else { text::windows_1252(b) });
|
||||
}
|
||||
|
||||
/// Character `c` read from the file: skipped when it is a `\uN` fallback character.
|
||||
fn put(&mut self, c: char) {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return;
|
||||
}
|
||||
self.high = None;
|
||||
self.text(c);
|
||||
}
|
||||
|
||||
/// Character `c` in the current destination.
|
||||
fn text(&mut self, c: char) {
|
||||
match self.group.dest {
|
||||
Dest::Body if self.group.chars.hidden => {}
|
||||
Dest::Body => {
|
||||
if std::mem::take(&mut self.cell_tab) {
|
||||
self.push('\t');
|
||||
}
|
||||
self.push(c);
|
||||
}
|
||||
Dest::Fonts if c == ';' => self.commit_font(),
|
||||
Dest::Fonts if self.font_name.len() < MAX_NAME => self.font_name.push(c),
|
||||
Dest::Colors if c == ';' => self.commit_color(),
|
||||
Dest::Fonts | Dest::Colors | Dest::Skip => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `c` to the document's text in the current character formatting.
|
||||
fn push(&mut self, c: char) {
|
||||
let chars = self.group.chars;
|
||||
let runs = self.spans.len();
|
||||
match self.spans.last_mut() {
|
||||
Some((s, f)) if *f == chars => s.push(c),
|
||||
_ if runs >= MAX_RUNS => {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
_ => self.spans.push((c.to_string(), chars)),
|
||||
}
|
||||
self.last = self.group.para;
|
||||
}
|
||||
|
||||
/// Ends the paragraph being read (unless its mark is hidden text).
|
||||
fn end_paragraph(&mut self) {
|
||||
if self.group.dest != Dest::Body || self.group.chars.hidden {
|
||||
return;
|
||||
}
|
||||
self.cell_tab = false;
|
||||
if self.paras.len() >= MAX_PARAGRAPHS {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
self.paras.push(self.group.para);
|
||||
self.push('\n');
|
||||
}
|
||||
|
||||
/// Unicode character (or UTF-16 code unit) `n`, a negative `n` standing for `n + 65536`.
|
||||
fn unicode(&mut self, n: i32) {
|
||||
let unit = n.rem_euclid(0x10000) as u16;
|
||||
match (self.high.take(), unit) {
|
||||
(Some(high), 0xDC00..=0xDFFF) => {
|
||||
if let Some(Ok(c)) = char::decode_utf16([high, unit]).next() {
|
||||
self.text(c);
|
||||
}
|
||||
}
|
||||
(_, 0xD800..=0xDBFF) => self.high = Some(unit),
|
||||
(_, 0xDC00..=0xDFFF) => {}
|
||||
(_, unit) => {
|
||||
if let Some(c) = char::from_u32(u32::from(unit)) {
|
||||
self.text(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.skip = self.group.uc;
|
||||
}
|
||||
|
||||
/// Control word `w` with parameter `n`; `starred` when `\*` came before it.
|
||||
fn word(&mut self, w: &str, n: Option<i32>, starred: bool) {
|
||||
if self.skip > 0 {
|
||||
self.skip -= 1;
|
||||
return;
|
||||
}
|
||||
if w != "u" {
|
||||
self.high = None;
|
||||
}
|
||||
// The Unicode group of a `\upr` group is read in place of its ANSI group.
|
||||
if w == "ud"
|
||||
&& self.overflow == 0
|
||||
&& let Some(parent) = self.stack.last().filter(|g| g.upr)
|
||||
{
|
||||
self.group.dest = parent.dest;
|
||||
return;
|
||||
}
|
||||
// An ignorable destination (`\*`): VectorCraft reads none of them.
|
||||
if starred {
|
||||
self.group.dest = Dest::Skip;
|
||||
return;
|
||||
}
|
||||
if self.group.dest == Dest::Skip {
|
||||
return;
|
||||
}
|
||||
// `\b`, `\b1`: on; `\b0`: off.
|
||||
let on = n != Some(0);
|
||||
let v = n.unwrap_or(0);
|
||||
let para = self.group.para;
|
||||
let c = &mut self.group.chars;
|
||||
let p = &mut self.group.para;
|
||||
match w {
|
||||
"fonttbl" => self.group.dest = Dest::Fonts,
|
||||
"colortbl" => self.group.dest = Dest::Colors,
|
||||
"upr" => self.group.upr = true,
|
||||
"stylesheet" | "info" | "pict" | "object" | "shp" | "nonshppict" | "header" | "headerl" | "headerr" | "headerf" | "footer"
|
||||
| "footerl" | "footerr" | "footerf" | "footnote" | "annotation" | "fldinst" | "pn" | "listtable" | "listoverridetable" | "revtbl"
|
||||
| "rsidtbl" | "filetbl" | "bkmkstart" | "bkmkend" | "xe" | "tc" | "txe" | "template" | "ftnsep" | "ftnsepc" | "ftncn" | "aftnsep"
|
||||
| "aftnsepc" | "aftncn" | "nonesttables" => self.group.dest = Dest::Skip,
|
||||
"ansi" | "pc" | "pca" => self.mac = false,
|
||||
"mac" => self.mac = true,
|
||||
"ansicpg" => self.mac = v == 10000,
|
||||
"deff" => self.default_font = v,
|
||||
"f" if self.group.dest == Dest::Fonts => {
|
||||
self.commit_font();
|
||||
self.font = v;
|
||||
}
|
||||
"f" => c.font = Some(v),
|
||||
"red" | "green" | "blue" if self.group.dest == Dest::Colors => {
|
||||
let i = match w {
|
||||
"red" => 0,
|
||||
"green" => 1,
|
||||
_ => 2,
|
||||
};
|
||||
if let Some(slot) = self.rgb.get_mut(i) {
|
||||
*slot = Some(v.clamp(0, 255) as u8);
|
||||
}
|
||||
}
|
||||
"uc" => self.group.uc = usize::try_from(v).unwrap_or(0),
|
||||
"u" => self.unicode(v),
|
||||
"par" | "line" | "sect" => self.end_paragraph(),
|
||||
"row" | "nestrow" => {
|
||||
self.cell_tab = false;
|
||||
self.end_paragraph();
|
||||
}
|
||||
"cell" | "nestcell" if self.group.dest == Dest::Body && !c.hidden => {
|
||||
if self.cell_tab {
|
||||
self.push('\t');
|
||||
}
|
||||
self.cell_tab = true;
|
||||
}
|
||||
"tab" => self.text('\t'),
|
||||
"emdash" => self.text('\u{2014}'),
|
||||
"endash" => self.text('\u{2013}'),
|
||||
"bullet" => self.text('\u{2022}'),
|
||||
"lquote" => self.text('\u{2018}'),
|
||||
"rquote" => self.text('\u{2019}'),
|
||||
"ldblquote" => self.text('\u{201C}'),
|
||||
"rdblquote" => self.text('\u{201D}'),
|
||||
"emspace" => self.text('\u{2003}'),
|
||||
"enspace" => self.text('\u{2002}'),
|
||||
"qmspace" => self.text('\u{2005}'),
|
||||
"zwj" => self.text('\u{200D}'),
|
||||
"zwnj" => self.text('\u{200C}'),
|
||||
"ltrmark" => self.text('\u{200E}'),
|
||||
"rtlmark" => self.text('\u{200F}'),
|
||||
"plain" => *c = Chars::default(),
|
||||
"fs" => c.half_points = n.unwrap_or(24),
|
||||
"b" => c.bold = on,
|
||||
"i" => c.italic = on,
|
||||
"ul" | "uld" | "uldash" | "uldashd" | "uldashdd" | "uldb" | "ulhwave" | "ulldash" | "ulth" | "ulthd" | "ulthdash" | "ulthdashd"
|
||||
| "ulthdashdd" | "ulthldash" | "ululdbwave" | "ulw" | "ulwave" => c.underline = on,
|
||||
"ulnone" => c.underline = false,
|
||||
"strike" | "striked" => c.strike = on,
|
||||
"caps" => c.caps = on,
|
||||
"scaps" => c.small_caps = on,
|
||||
"super" => c.script = Script::Super,
|
||||
"sub" => c.script = Script::Sub,
|
||||
"nosupersub" => c.script = Script::Normal,
|
||||
"up" => c.shift = n.unwrap_or(6),
|
||||
"dn" => c.shift = n.unwrap_or(6).saturating_neg(),
|
||||
"cf" => c.color = v,
|
||||
"v" => c.hidden = on,
|
||||
"pard" => *p = Para::default(),
|
||||
"ql" => p.justify = Justify::Left,
|
||||
"qc" => p.justify = Justify::Center,
|
||||
"qr" => p.justify = Justify::Right,
|
||||
"qj" => p.justify = Justify::JustifyLeft,
|
||||
"qd" => p.justify = Justify::JustifyAll,
|
||||
"li" => p.left = v,
|
||||
"ri" => p.right = v,
|
||||
"fi" => p.first = v,
|
||||
"sb" => p.before = v,
|
||||
"sa" => p.after = v,
|
||||
"rtlpar" => p.rtl = Some(true),
|
||||
"ltrpar" => p.rtl = Some(false),
|
||||
_ => {}
|
||||
}
|
||||
// Paragraph formatting after a paragraph's last character still applies to it.
|
||||
if self.group.para != para && self.group.dest == Dest::Body {
|
||||
self.last = self.group.para;
|
||||
}
|
||||
}
|
||||
|
||||
/// The character attributes of formatting `c`.
|
||||
fn style(&self, c: &Chars) -> CharStyle {
|
||||
let mut s = CharStyle::default();
|
||||
if let Some(name) = self.fonts.get(&c.font.unwrap_or(self.default_font)) {
|
||||
s.font_family.clone_from(name);
|
||||
}
|
||||
s.font_style = match (c.bold, c.italic) {
|
||||
(true, true) => "Bold Italic",
|
||||
(true, false) => "Bold",
|
||||
(false, true) => "Italic",
|
||||
(false, false) => "Regular",
|
||||
}
|
||||
.into();
|
||||
s.size = (f64::from(c.half_points) / 2.0).clamp(0.1, 1296.0);
|
||||
s.baseline_shift = (f64::from(c.shift) / 2.0).clamp(-1296.0, 1296.0);
|
||||
if let Some(Some([r, g, b])) = usize::try_from(c.color).ok().and_then(|i| self.colors.get(i)) {
|
||||
s.fill = Paint::solid(vectorcraft_color::Color::rgb8(*r, *g, *b));
|
||||
}
|
||||
s.underline = c.underline;
|
||||
s.strikethrough = c.strike;
|
||||
s.all_caps = c.caps;
|
||||
// Document Setup's proportions replace these when the type is placed.
|
||||
s.small_caps = c.small_caps.then_some(70.0);
|
||||
s.position = match c.script {
|
||||
Script::Normal => CharPosition::Normal,
|
||||
Script::Super => CharPosition::Superscript(ScriptMetrics::DEFAULT),
|
||||
Script::Sub => CharPosition::Subscript(ScriptMetrics::DEFAULT),
|
||||
};
|
||||
s
|
||||
}
|
||||
|
||||
/// The text read, without blank paragraphs at its end, and each paragraph's attributes.
|
||||
fn finish(mut self, cmd: &str) -> Result<Story> {
|
||||
let too_many = || {
|
||||
bad(
|
||||
cmd,
|
||||
format!(
|
||||
"the RTF file has too many paragraphs or formatting changes to place as type (at most {MAX_PARAGRAPHS} paragraphs and {MAX_RUNS} runs)"
|
||||
),
|
||||
)
|
||||
};
|
||||
if self.full {
|
||||
return Err(too_many());
|
||||
}
|
||||
self.paras.push(self.last);
|
||||
let mut all: String = self.spans.iter().map(|(s, _)| s.as_str()).collect();
|
||||
while self.paras.len() > 1
|
||||
&& let Some(at) = all.rfind('\n')
|
||||
&& all.get(at + 1..).is_some_and(|tail| tail.trim().is_empty())
|
||||
{
|
||||
all.truncate(at);
|
||||
self.paras.pop();
|
||||
}
|
||||
if self.paras.len() > MAX_PARAGRAPHS {
|
||||
return Err(too_many());
|
||||
}
|
||||
let mut keep = all.len();
|
||||
let mut runs: Vec<TextRun> = vec![];
|
||||
for (s, chars) in &self.spans {
|
||||
let piece = s.get(..keep.min(s.len())).unwrap_or_default();
|
||||
keep -= piece.len();
|
||||
if piece.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let style = self.style(chars);
|
||||
match runs.last_mut() {
|
||||
Some(r) if r.style == style => r.text.push_str(piece),
|
||||
_ => runs.push(TextRun::new(piece, style)),
|
||||
}
|
||||
}
|
||||
let twips = |v: i32| (f64::from(v) / 20.0).clamp(-1296.0, 1296.0);
|
||||
let paras = self
|
||||
.paras
|
||||
.iter()
|
||||
.map(|p| ParaStyle {
|
||||
justify: p.justify,
|
||||
left_indent: twips(p.left),
|
||||
right_indent: twips(p.right),
|
||||
first_line_indent: twips(p.first),
|
||||
space_before: twips(p.before),
|
||||
space_after: twips(p.after),
|
||||
direction: p.rtl.map(|rtl| if rtl { ParaDirection::RightToLeft } else { ParaDirection::LeftToRight }),
|
||||
..ParaStyle::default()
|
||||
})
|
||||
.collect();
|
||||
Ok(Story { runs, paras })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn read(rtf: &str) -> Story {
|
||||
import(rtf.as_bytes(), "file.place").unwrap()
|
||||
}
|
||||
|
||||
fn text_of(s: &Story) -> String {
|
||||
s.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_escapes_skip_their_fallback_characters() {
|
||||
// `\u` then 8364: the euro sign.
|
||||
let s = read(concat!(r"{\rtf1\ansi\uc1 caf\u233?\uc2 \u", "8364", r"\'80\'80 \uc1\u-10179?\u-8704? end\'93q\'94}"));
|
||||
assert_eq!(text_of(&s), "café€ 😀 end“q”");
|
||||
assert_eq!(text_of(&read(r"{\rtf1\uc1\u233\bin1 ?X}")), "éX", "binary data is one fallback character");
|
||||
let mac = read(r"{\rtf1\mac \'8e\'a5}");
|
||||
assert_eq!(text_of(&mac), "é•", "Mac Roman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upr_groups_read_their_unicode_text() {
|
||||
let s = read(concat!(r"{\rtf1 a{\upr{\f0 b?}{\*\ud{\f0 c\u", "945", r"?}}}d}"));
|
||||
assert_eq!(text_of(&s), "acαd");
|
||||
let fonts = read(r"{\rtf1{\fonttbl{\upr{\f0 Ansi Name;}{\*\ud{\f0 Unicode Name;}}}}x}");
|
||||
assert_eq!(fonts.runs.first().map(|r| r.style.font_family.as_str()), Some("Unicode Name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destinations_that_are_not_text_are_skipped() {
|
||||
let s = read(concat!(
|
||||
r"{\rtf1{\fonttbl{\f0 Arial;}}{\*\generator Some Writer;}{\info{\title T}}{\stylesheet{\s1 Heading;}}",
|
||||
r"{\header H}A{\*\bkmkstart b}{\field{\*\fldinst HYPERLINK x}{\fldrslt link}}{\pict\pngblip 89504e47}\bin2 {}B}",
|
||||
"trailing"
|
||||
));
|
||||
assert_eq!(text_of(&s), "AlinkB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_text_and_the_text_for_older_readers_are_skipped() {
|
||||
let s = read(r"{\rtf1 One{\v hidden\par more\cell\par}\sect Two {\nonesttables flat copy}three}");
|
||||
assert_eq!(text_of(&s), "One\nTwo three");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cells_are_tabs_and_rows_paragraphs() {
|
||||
let s = read(r"{\rtf1\trowd\cellx100\cellx200 a\cell b\cell\row\trowd c\cell\cell d\cell\row}");
|
||||
assert_eq!(text_of(&s), "a\tb\nc\t\td");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_formatting_after_the_last_character_applies() {
|
||||
let s = read(r"{\rtf1 One\par Two\qc\li400}");
|
||||
let justify: Vec<Justify> = s.paras.iter().map(|p| p.justify).collect();
|
||||
assert_eq!(justify, [Justify::Left, Justify::Center]);
|
||||
assert_eq!(s.paras.last().map(|p| p.left_indent), Some(20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_and_stray_braces_end_cleanly() {
|
||||
let deep = format!("{{\\rtf1 {}x{}}}", "{".repeat(5000), "}".repeat(5000));
|
||||
assert_eq!(text_of(&read(&deep)), "x");
|
||||
assert_eq!(text_of(&read(r"{\rtf1 unclosed {\b group")), "unclosed group");
|
||||
assert!(import(b"{\\rt", "file.place").is_err(), "not RTF");
|
||||
assert!(import(b"Plain words", "file.place").is_err(), "not RTF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_paragraphs_or_runs_are_refused() {
|
||||
let most = format!("{{\\rtf1 {}}}", "a\\par ".repeat(MAX_PARAGRAPHS));
|
||||
assert_eq!(read(&most).paras.len(), MAX_PARAGRAPHS);
|
||||
let paras = format!("{{\\rtf1 {}a}}", "a\\par ".repeat(MAX_PARAGRAPHS));
|
||||
assert!(import(paras.as_bytes(), "file.place").is_err());
|
||||
let marks = format!("{{\\rtf1 {}}}", "a\\par ".repeat(MAX_PARAGRAPHS + 1));
|
||||
assert!(import(marks.as_bytes(), "file.place").is_err());
|
||||
let runs = format!("{{\\rtf1 {}}}", "{\\b a}b".repeat(MAX_RUNS / 2 + 1));
|
||||
assert!(import(runs.as_bytes(), "file.place").is_err());
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,8 @@ pub(super) const FORMAT: fileio::Format = fileio::Format {
|
||||
options: &[],
|
||||
};
|
||||
|
||||
/// The most bytes a text file placed as type may have.
|
||||
const MAX_TEXT: usize = 4 << 20;
|
||||
/// The most bytes a text or RTF file placed as type may have.
|
||||
pub(super) const MAX_TEXT: usize = 4 << 20;
|
||||
|
||||
/// Text Import Options (`file.place`'s `text` param).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
@@ -95,7 +95,7 @@ fn decode(bytes: &[u8], o: TextOptions) -> String {
|
||||
}
|
||||
|
||||
/// Byte `b` of Windows-1252.
|
||||
fn windows_1252(b: u8) -> char {
|
||||
pub(super) fn windows_1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', //
|
||||
'\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
|
||||
@@ -107,7 +107,7 @@ fn windows_1252(b: u8) -> char {
|
||||
}
|
||||
|
||||
/// Byte `b` of Mac Roman.
|
||||
fn mac_roman(b: u8) -> char {
|
||||
pub(super) fn mac_roman(b: u8) -> char {
|
||||
const HIGH: [char; 128] = [
|
||||
'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', //
|
||||
'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', //
|
||||
@@ -190,11 +190,41 @@ fn tabs_for_spaces(s: &str, n: usize) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The area type of `text` filling `frame` (at the origin; placing moves it).
|
||||
pub(super) fn area_text(text: String, frame: Rect) -> vectorcraft_doc::TextObject {
|
||||
/// The text placed type gets from a text or RTF file.
|
||||
pub(super) struct Story {
|
||||
/// The text in runs of one character formatting; paragraphs are separated by `\n`.
|
||||
pub(super) runs: Vec<vectorcraft_doc::TextRun>,
|
||||
/// Each paragraph's attributes; empty: the defaults.
|
||||
pub(super) paras: Vec<vectorcraft_doc::ParaStyle>,
|
||||
}
|
||||
|
||||
impl Story {
|
||||
/// `text` in the default character and paragraph attributes.
|
||||
pub(super) fn plain(text: String) -> Self {
|
||||
Self { runs: vec![vectorcraft_doc::TextRun::new(text, vectorcraft_doc::CharStyle::default())], paras: vec![] }
|
||||
}
|
||||
|
||||
/// The text without its formatting.
|
||||
pub(super) fn plain_text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The area type of `story` filling `frame` (at the origin; placing moves it), its superscripts,
|
||||
/// subscripts and small caps in the proportions of `setup` (Document Setup).
|
||||
pub(super) fn area_text(story: Story, frame: Rect, setup: &vectorcraft_doc::DocSetup) -> vectorcraft_doc::TextObject {
|
||||
let mut t = vectorcraft_doc::TextObject::point(Point::ORIGIN, "", vectorcraft_doc::CharStyle::default());
|
||||
if let Some(run) = t.runs.first_mut() {
|
||||
run.text = text;
|
||||
if !story.runs.is_empty() {
|
||||
t.runs = story.runs;
|
||||
}
|
||||
for run in &mut t.runs {
|
||||
run.style.position = crate::cmd::docsetup::position_in(setup, run.style.position);
|
||||
if run.style.small_caps.is_some() {
|
||||
run.style.small_caps = Some(setup.small_caps_size);
|
||||
}
|
||||
}
|
||||
if !story.paras.is_empty() {
|
||||
t.set_paragraph_styles(story.paras);
|
||||
}
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) };
|
||||
crate::cmd::typecmd::refresh_bounds(&mut t);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! File → Place of a text file: Text Import Options, and the text set as area type.
|
||||
//! File → Place of a text file (Text Import Options) or an RTF file: the text set as area type.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeKind, TextKind};
|
||||
@@ -75,11 +75,83 @@ fn text_files_report_and_queue_with_their_options() {
|
||||
assert!(close(frame, Rect::new(50.0, 60.0, 130.0, 100.0)), "{frame:?}");
|
||||
}
|
||||
|
||||
/// An RTF file with a font table, a color table, a bold run, an italic run, a superscript and a
|
||||
/// centered, indented second paragraph.
|
||||
const LETTER_RTF: &str = concat!(
|
||||
r"{\rtf1\ansi\deff0{\fonttbl{\f0\froman Times New Roman;}{\f1\fswiss Arial;}}",
|
||||
r"{\colortbl;\red255\green0\blue0;}{\*\generator Some Writer;}{\info{\title Notes}}",
|
||||
"\r\n",
|
||||
r"\pard\plain\fs24 Plain and {\b bold} words{\super 2}.\par",
|
||||
"\r\n",
|
||||
r"\pard\qc\li720\sa240\f1\fs36\cf1 Centered {\i caf\'e9}\par",
|
||||
"\r\n}"
|
||||
);
|
||||
|
||||
#[test]
|
||||
fn rtf_places_as_area_type_with_its_character_and_paragraph_formatting() {
|
||||
let mut s = session();
|
||||
s.execute("document.setup", &json!({"superscript": {"size": 50, "position": 40}})).unwrap();
|
||||
let r = s.execute("file.place", &json!({"name": "letter.rtf", "dataBase64": vectorcraft_format::base64_encode(LETTER_RTF.as_bytes())})).unwrap();
|
||||
assert_eq!((r["format"].as_str(), r["name"].as_str()), (Some("rtf"), Some("letter.rtf")));
|
||||
let (body, frame) = placed(&s, &r);
|
||||
assert_eq!(body, "Plain and bold words2.\nCentered café");
|
||||
assert!(close(frame, Rect::new(36.0, 36.0, 364.0, 264.0)), "the artboard less a 36 pt margin: {frame:?}");
|
||||
let n = s.doc().unwrap().doc.node(NodeId(r["ids"][0].as_u64().unwrap())).unwrap().clone();
|
||||
let NodeKind::Text(t) = &n.kind else { panic!("not type") };
|
||||
let runs: Vec<_> = t.runs.iter().map(|r| (r.text.as_str(), r.style.font_family.as_str(), r.style.font_style.as_str(), r.style.size)).collect();
|
||||
assert_eq!(
|
||||
runs,
|
||||
[
|
||||
("Plain and ", "Times New Roman", "Regular", 12.0),
|
||||
("bold", "Times New Roman", "Bold", 12.0),
|
||||
(" words", "Times New Roman", "Regular", 12.0),
|
||||
("2", "Times New Roman", "Regular", 12.0),
|
||||
(".\n", "Times New Roman", "Regular", 12.0),
|
||||
("Centered ", "Arial", "Regular", 18.0),
|
||||
("café", "Arial", "Italic", 18.0),
|
||||
]
|
||||
);
|
||||
let superscript = vectorcraft_doc::ScriptMetrics { size: 50.0, position: 40.0 };
|
||||
assert_eq!(t.runs[3].style.position, vectorcraft_doc::CharPosition::Superscript(superscript), "in Document Setup's proportions");
|
||||
let red = vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0));
|
||||
assert_eq!((t.runs[0].style.fill == red, t.runs[5].style.fill == red, t.runs[6].style.fill == red), (false, true, true));
|
||||
let paras = t.paragraph_styles();
|
||||
let summary: Vec<_> = paras.iter().map(|p| (p.justify, p.left_indent, p.space_after)).collect();
|
||||
assert_eq!(summary, [(vectorcraft_doc::Justify::Left, 0.0, 0.0), (vectorcraft_doc::Justify::Center, 36.0, 12.0)]);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((st.history.undo.len(), st.selection.objects.len()), (2, 1), "Document Setup and Place, each one undo step");
|
||||
// Info and the place cursor report it as RTF.
|
||||
let file = json!({"name": "letter.rtf", "dataBase64": vectorcraft_format::base64_encode(LETTER_RTF.as_bytes())});
|
||||
let info = s.execute("file.place.info", &file).unwrap();
|
||||
assert_eq!((info["format"].as_str(), info["width"].as_f64()), (Some("rtf"), Some(468.0)));
|
||||
let q = s.execute("file.place.queue", &json!({"files": [file]})).unwrap();
|
||||
assert_eq!((q["count"].as_u64(), q["files"][0]["format"].as_str()), (Some(1), Some("rtf")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_rtf_fails_cleanly() {
|
||||
let mut s = session();
|
||||
let place = |s: &mut Session, name: &str, bytes: &[u8]| {
|
||||
s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}))
|
||||
};
|
||||
assert!(place(&mut s, "a.rtf", b"Plain words, not RTF").is_err(), "an .rtf file that isn't RTF");
|
||||
assert!(place(&mut s, "a.rtf", br"{\rtf1{\fonttbl{\f0 Arial;}}\par\par }").is_err(), "no text to place");
|
||||
assert!(place(&mut s, "a.rtf", br"{\rtf1 {\pict\bin999999 ").is_err(), "binary data past the end of the file");
|
||||
// Text, a stray brace and a cut-off escape after the closing brace are ignored.
|
||||
let r = place(&mut s, "a.rtf", br"{\rtf1 Cut {\b off}} after}\'9").unwrap();
|
||||
assert_eq!(placed(&s, &r).0, "Cut off");
|
||||
// A .txt file is plain text even when it holds RTF.
|
||||
let r = place(&mut s, "a.txt", br"{\rtf1 x}").unwrap();
|
||||
assert_eq!((r["format"].as_str(), placed(&s, &r).0.as_str()), (Some("txt"), r"{\rtf1 x}"));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "failed places leave no undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_reads_what_open_reads_and_text_files() {
|
||||
use cmd::fileio::{OPEN_EXTS, PLACE_EXTS, TEXT_EXTS};
|
||||
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS].concat());
|
||||
use cmd::fileio::{OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS};
|
||||
assert_eq!(PLACE_EXTS, [OPEN_EXTS, TEXT_EXTS, RTF_EXTS].concat());
|
||||
let filters: Vec<_> = cmd::fileio::place_filters().collect();
|
||||
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("Text", TEXT_EXTS))));
|
||||
assert!(!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt")), "Open doesn't read text files");
|
||||
assert_eq!((filters.first(), filters.last()), (Some(&("All placeable files", PLACE_EXTS)), Some(&("RTF", RTF_EXTS))));
|
||||
assert_eq!(filters.get(filters.len() - 2), Some(&("Text", TEXT_EXTS)));
|
||||
assert!(!cmd::fileio::open_filters().any(|(_, exts)| exts.contains(&"txt") || exts.contains(&"rtf")), "Open doesn't read text or RTF files");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Untrusted text files placed with File → Place never crash: any bytes, with any Text Import
|
||||
//! Options, place as area type or fail with an error.
|
||||
//! Options, place as area type or fail with an error, and so do hostile RTF files.
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test text_import_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
@@ -42,4 +42,46 @@ proptest! {
|
||||
});
|
||||
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_rtf_places_without_panics(
|
||||
tokens in prop::collection::vec(arb_rtf_token(), 0..200),
|
||||
name in prop::sample::select(vec!["fuzz.rtf", "fuzz.doc"]),
|
||||
) {
|
||||
vectorcraft_text::FontDb::global().set_system_fallback(false);
|
||||
let data = [&b"{\\rtf1"[..], &tokens.concat()].concat();
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let p = json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(&data), "thumbnail": 8});
|
||||
let _ = s.execute("file.place.info", &p);
|
||||
let _ = s.execute("file.place", &p);
|
||||
});
|
||||
prop_assert!(r.is_ok(), "panicked: {:?}", r.err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Control words the RTF reader knows, and one it doesn't.
|
||||
const RTF_WORDS: &str = "par line pard plain b i ul ulnone fs f cf u uc qc qj li fi sb sa rtlpar cell row tab fonttbl colortbl red blue \
|
||||
pict bin deff super sub up dn scaps caps ansicpg mac stylesheet upr ud zzunknown";
|
||||
|
||||
/// A piece of an RTF file: a brace, a control word the reader knows or doesn't (with a parameter
|
||||
/// in or out of range, or none), a control symbol, text or raw bytes.
|
||||
fn arb_rtf_token() -> impl Strategy<Value = Vec<u8>> {
|
||||
let words = prop::sample::select(RTF_WORDS.split_whitespace().collect::<Vec<_>>());
|
||||
let param = prop::option::of(prop_oneof![-70_000i64..70_000, Just(i64::from(i32::MAX)), Just(-99_999_999_999)]);
|
||||
let word = |(w, p, space): (&str, Option<i64>, bool)| {
|
||||
let p = p.map(|v| v.to_string()).unwrap_or_default();
|
||||
format!("\\{w}{p}{}", if space { " " } else { "" }).into_bytes()
|
||||
};
|
||||
prop_oneof![
|
||||
Just(b"{".to_vec()),
|
||||
Just(b"}".to_vec()),
|
||||
Just(b"\\*".to_vec()),
|
||||
Just(b";".to_vec()),
|
||||
(words, param, any::<bool>()).prop_map(word),
|
||||
prop::sample::select(vec!["\\'e9", "\\'9", "\\~", "\\-", "\\_", "\\\\", "\\{", "\\}", "\\\r\n", "\\|"]).prop_map(|s| s.as_bytes().to_vec()),
|
||||
"[a-z ]{0,6}".prop_map(String::into_bytes),
|
||||
prop::collection::vec(any::<u8>(), 0..4),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ from the public specifications, and users' own `.ai` files are used locally only
|
||||
|
||||
| | Illustrator | VectorCraft | Notes |
|
||||
|---|---:|---:|---|
|
||||
| Formats opened or placed | ~30 | 22 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, RTF/Word, PS |
|
||||
| Formats opened or placed | ~30 | 23 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, JPEG 2000, PICT, PCX, Pixar, Word, PS |
|
||||
| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (no Illustrator editing data) or as legacy version 7 (layers and groups, one artboard per file) |
|
||||
| Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD |
|
||||
| **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting |
|
||||
@@ -110,7 +110,8 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
| FreeHand `.fh*` | open (legacy) | no | no | |
|
||||
| CorelDRAW `.cdr` | open | no | no | |
|
||||
| Text `.txt` | place, export | place (Text Import Options) | yes | |
|
||||
| RTF / Word `.doc`/`.docx` | place | no | no | |
|
||||
| RTF `.rtf` | place | place (area type with fonts, size, bold, italic, underline, strikethrough, caps, superscript and subscript, color, alignment, indents and paragraph spacing) | no | `tests_textimport.rs`, `place/rtf.rs`; fuzzed (`text_import_fuzz.rs`); `\'hh` bytes read as Windows-1252 or Mac Roman only |
|
||||
| Word `.doc`/`.docx` | place | no | no | |
|
||||
| CSS | export (CSS Properties) | — | yes | |
|
||||
| OBJ / USDA / glTF | 3D export | — | no | needs 3D (gap G3) |
|
||||
| Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) |
|
||||
|
||||
@@ -2524,6 +2524,12 @@ bounds, or the artboard less a 36 pt margin.
|
||||
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/notes.txt","text":{"removeLineReturns":true,"removeParagraphReturns":true}}}}
|
||||
```
|
||||
|
||||
An RTF file (any file but a `.txt` that starts with `{\rtf`) is placed the same way, without `text` options, and keeps
|
||||
its formatting: each run's font, size, bold and italic, underline, strikethrough, all caps, small caps, superscript or
|
||||
subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction.
|
||||
Table cells are separated by tabs and each row is a paragraph; hidden text, pictures, headers, footers and notes are
|
||||
left out. `file.place` answers `format: "rtf"`.
|
||||
|
||||
## PDF presets
|
||||
|
||||
`pdf.preset.list` lists the built-in presets (`VectorCraft Default`, which preserves editing, then High Quality Print,
|
||||
|
||||
@@ -649,6 +649,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- A PDF, PDF-compatible `.ai` or `.ait` opened from its pages or its editing copy opens with the File Info that its XMP packet and its document information dictionary give: the author, description, keywords, copyright status, notice and URL, and the created and modified dates, the XMP packet's value where both give one; the title is not changed (`crates/pdf/src/tests_file_info.rs`, `crates/engine/src/tests_fileinfo.rs`).
|
||||
- `file.print`, `print.preview` and `print.setup` take a print preset by name (`preset`: `[Default]` or a saved preset, in any case): its settings are used instead of the document's, with `settings` over them, `print.setup` saves them with the document, and an unknown name is refused with the list of presets.
|
||||
- Place and the Links panel, Package (placed `.vectorcraft` documents packaged with their own linked files and fonts, relinked to the copies: #777), File Info, Print with print presets, PostScript output and print tiling, slices and Save for Web, Export for Screens and Asset Export, CSS Properties, PNG/PDF/SVG/text clipboard flavours; files copied in a file manager paste as art (SVG, PDF, EMF/WMF, bitmaps) on every desktop platform, and Linux/Wayland reads the compositor's clipboard (#398); on Linux the Paste menu checks the clipboard off the UI thread, so an X11 owner that never answers can't freeze the window (#510); screenshots and other copied pictures paste as embedded images at the view centre everywhere, the web included (#457).
|
||||
- File › Place reads RTF files as area type with their formatting: each run's font, size, bold and italic, underline, strikethrough, caps, superscript or subscript, baseline shift and color, and each paragraph's alignment, indents, space before and after and direction. Table cells are separated by tabs; hidden text, pictures, headers, footers and notes are left out (`file.place`, `crates/engine/src/tests_textimport.rs`).
|
||||
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
|
||||
- Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected.
|
||||
- In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one.
|
||||
|
||||
Reference in New Issue
Block a user