mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 18:27:02 -06:00
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.
158 lines
8.8 KiB
Rust
158 lines
8.8 KiB
Rust
//! 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};
|
|
use vectorcraft_geom::Rect;
|
|
|
|
use super::*;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn place_text(s: &mut Session, text: &[u8], extra: Value) -> Value {
|
|
let mut p = json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(text)});
|
|
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
|
|
o.extend(e);
|
|
}
|
|
s.execute("file.place", &p).unwrap()
|
|
}
|
|
|
|
/// The placed type's text and frame bounds.
|
|
fn placed(s: &Session, r: &Value) -> (String, Rect) {
|
|
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: {:?}", n.kind) };
|
|
assert!(matches!(t.kind, TextKind::Area { .. }), "area type");
|
|
(t.plain_text(), n.geometric_bounds().unwrap())
|
|
}
|
|
|
|
fn close(a: Rect, b: Rect) -> bool {
|
|
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-6)
|
|
}
|
|
|
|
#[test]
|
|
fn crlf_with_returns_removed_places_one_paragraph_per_block_as_area_type() {
|
|
let mut s = session();
|
|
let text = b"Dear reader,\r\nthis is the first\r\nparagraph.\r\n\r\nAnd here\r\nthe second.\r\n";
|
|
let opts = json!({"text": {"removeLineReturns": true, "removeParagraphReturns": true, "replaceSpaces": 3}});
|
|
let r = place_text(&mut s, text, opts);
|
|
assert_eq!((r["format"].as_str(), r["name"].as_str(), r["linked"].as_bool()), (Some("txt"), Some("notes.txt"), Some(false)));
|
|
let (body, frame) = placed(&s, &r);
|
|
assert_eq!(body, "Dear reader, this is the first paragraph.\nAnd\there the second.");
|
|
assert!(close(frame, Rect::new(36.0, 36.0, 364.0, 264.0)), "the artboard less a 36 pt margin: {frame:?}");
|
|
let st = s.doc().unwrap();
|
|
assert_eq!((st.history.undo.len(), st.selection.objects.len()), (1, 1), "one undo step, selected");
|
|
// Without options each line is a paragraph; `rect` sets the frame.
|
|
let r = place_text(&mut s, text, json!({"rect": [10, 20, 100, 50]}));
|
|
let (body, frame) = placed(&s, &r);
|
|
assert_eq!(body, "Dear reader,\nthis is the first\nparagraph.\n\nAnd here\nthe second.");
|
|
assert!(close(frame, Rect::new(10.0, 20.0, 110.0, 70.0)), "{frame:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn text_files_report_and_queue_with_their_options() {
|
|
let mut s = session();
|
|
let file = json!({"name": "a.txt", "dataBase64": vectorcraft_format::base64_encode(b"caf\xe9")});
|
|
let info = s.execute("file.place.info", &file).unwrap();
|
|
assert_eq!((info["format"].as_str(), info["width"].as_f64(), info["height"].as_f64()), (Some("txt"), Some(468.0), Some(648.0)));
|
|
// Not UTF-8: read as Windows-1252, or Mac Roman for a Mac file.
|
|
let r = place_text(&mut s, b"caf\x8e", json!({"text": {"characterSet": "ansi", "platform": "mac"}}));
|
|
assert_eq!(placed(&s, &r).0, "café");
|
|
let r = s.execute("file.place.queue", &json!({"files": [file], "text": {"characterSet": "ansi"}})).unwrap();
|
|
assert_eq!((r["count"].as_u64(), r["files"][0]["format"].as_str()), (Some(1), Some("txt")));
|
|
assert_eq!(s.tool_options()["name"], "a.txt");
|
|
let bad = json!({"name": "a.txt", "dataBase64": "YQ==", "text": {"platform": "beos"}});
|
|
assert!(s.execute("file.place", &bad).is_err());
|
|
let blank = json!({"name": "blank.txt", "dataBase64": vectorcraft_format::base64_encode(b" \r\n\t\r\n")});
|
|
assert!(s.execute("file.place", &blank).is_err(), "no text to place");
|
|
// Replace: the frame takes the replaced object's bounds.
|
|
s.execute("shape.rectangle", &json!({"x": 50, "y": 60, "width": 80, "height": 40})).unwrap();
|
|
let r = place_text(&mut s, b"Swap", json!({"replace": true}));
|
|
let (body, frame) = placed(&s, &r);
|
|
assert_eq!(body, "Swap");
|
|
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, 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(&("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");
|
|
}
|