M4.54: Placing a text file asks for Text Import Options and sets it as area type

file.place of a .txt file takes `text: {characterSet: unicode|ansi, platform: windows|mac,
removeLineReturns, removeParagraphReturns, replaceSpaces}`: Unicode reads UTF-8 or UTF-16 by its
byte-order mark (other bytes as the platform's 8-bit set), ANSI reads Windows-1252 or Mac Roman;
CR LF, CR and LF all break lines; joining lines makes each block one paragraph, blank lines can
go, and runs of spaces can become tabs. The text becomes area type filling `rect`, the replaced
object's bounds, or the artboard less a 36 pt margin (type reflows rather than scaling), as one
undo step. The place cursor and file.place.info take text files too, and File → Place lists them.

In the app, placing a text file (the Place dialog, a drop, file.place without options) opens the
Text Import Options dialog. Its dropdown rows share a form helper with Document Setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic
2026-10-05 10:07:23 +07:00
co-authored by Claude Opus 5.5
parent 2e5ec7b251
commit 2724263d00
15 changed files with 624 additions and 24 deletions
+16 -3
View File
@@ -293,17 +293,30 @@ pub const FORMATS: &[Format] = &[
pub const OPEN_EXTS: &[&str] =
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"];
/// 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`].
pub const PLACE_EXTS: &[&str] =
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "txt"];
/// One dialog filter per readable format.
fn format_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions))
}
/// Open-dialog filters: "All readable files" first, then one per readable format, then swatch
/// libraries (which open in the library panel) and flattener presets (imported).
pub fn open_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
place_filters()
std::iter::once(("All readable files", OPEN_EXTS))
.chain(format_filters())
.chain(std::iter::once(("Swatch libraries", super::swatchlib::LIBRARY_EXTS)))
.chain(std::iter::once(("Flattener presets", super::flatten::PRESET_EXTS)))
}
/// File → Place dialog filters: "All readable files", then one per readable format.
/// File → Place dialog filters: "All placeable files", then one per readable format, then text.
pub fn place_filters() -> impl Iterator<Item = (&'static str, &'static [&'static str])> {
std::iter::once(("All readable files", OPEN_EXTS)).chain(FORMATS.iter().filter(|f| f.read).map(|f| (f.label, f.extensions)))
std::iter::once(("All placeable files", PLACE_EXTS)).chain(format_filters()).chain(std::iter::once(("Text", TEXT_EXTS)))
}
/// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`).
+42 -9
View File
@@ -8,6 +8,8 @@
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
//! (`crop: "bounding"`).
//! - A text file becomes area type, decoded and cleaned up with the Text Import Options (`text`,
//! [`text`]).
//!
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
@@ -15,6 +17,7 @@
//! `place` tool) with several files, which emits one `file.place` per click or drag.
mod adopt;
mod text;
use std::sync::Arc;
@@ -34,6 +37,8 @@ const QUEUE: &str = "file.place.queue";
const MAX_QUEUE: usize = 100;
/// The longest thumbnail side `thumbnail` may ask for (px).
const MAX_THUMBNAIL: f64 = 512.0;
/// A text file's natural box (`file.place.info`, the place cursor): a letter page less 1 in margins.
const TEXT_FRAME: Rect = Rect::new(0.0, 0.0, 468.0, 648.0);
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -42,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…",
["File"],
Some("Cmd+Shift+P"),
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
has_doc,
place
),
@@ -69,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Load Place Cursor",
[],
None,
"{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}",
"{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, text?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}",
has_doc,
queue
),
@@ -81,6 +86,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),
Vector {
/// The loaded document: the source of the art's resources.
src: Box<Document>,
@@ -112,6 +119,13 @@ fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) {
fn load(p: &Value, cmd: &str) -> Result<Loaded> {
let src = fileio::source(p, cmd)?;
let name = fileio::file_name(src.name);
if fileio::TEXT_EXTS.contains(&fileio::extension(src.name).as_str()) {
let text = text::import(&src.bytes, text::TextOptions::parse(p, cmd)?, cmd)?;
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 });
}
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)")))?;
let page = match p.get("page") {
@@ -201,6 +215,7 @@ fn build(d: &mut Document, l: Loaded, link: Option<LinkInfo>) -> Node {
let im = ImageObject { key: img.key, width: img.width, height: img.height, xf: Affine::scale_non_uniform(sx, sy), link };
Node::new(d.alloc_id(), NodeKind::Image(im))
}
Art::Text(t) => Node::new(d.alloc_id(), NodeKind::Text(Box::new(text::area_text(t, l.natural)))),
Art::Vector { src, mut nodes, clip } => {
adopt::adopt(d, &src, &mut nodes);
let mut children: Vec<Arc<Node>> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect();
@@ -238,10 +253,10 @@ fn rect_param(p: &Value) -> Result<Option<Rect>> {
}
/// The transform that puts new art with the natural box `natural` where `old` is: an image's scale
/// and rotation relative to its 100% size (its resolution's) and its centre are kept; other objects
/// keep their centre.
fn replace_xf(d: &Document, old: &Node, natural: Rect) -> Affine {
if let NodeKind::Image(im) = &old.kind {
/// and rotation relative to its 100% size (its resolution's) and its centre are kept (with
/// `image_xf`); other objects keep their centre.
fn replace_xf(d: &Document, old: &Node, natural: Rect, image_xf: bool) -> Affine {
if image_xf && let NodeKind::Image(im) = &old.kind {
let (sx, sy) = pt_per_px(d.images.get(&im.key).and_then(|b| fileio::ppi::resolution(&b.bytes)));
let full = Affine::scale_non_uniform(sx, sy);
let centre = full * Point::new(im.width as f64 / 2.0, im.height as f64 / 2.0);
@@ -279,9 +294,20 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
(true, _) => return Err(bad(PLACE, "replace: select the one object to replace")),
};
let mut loaded = load(p, PLACE)?;
let old_node = old.and_then(|id| st.doc.node(id));
// Type reflows rather than scales: its frame takes the size it is placed at.
let is_text = matches!(loaded.art, Art::Text(_));
if is_text {
let size = match (old_node.and_then(Node::geometric_bounds), rect) {
(Some(b), _) => b.size(),
(None, Some(r)) => r.size(),
(None, None) => text_frame(&st.doc, at).size(),
};
loaded.natural = Rect::from_origin_size(Point::ORIGIN, size);
}
let natural = loaded.natural;
let m = match (old.and_then(|id| st.doc.node(id)), rect) {
(Some(o), _) => replace_xf(&st.doc, o, natural),
let m = match (old_node, rect) {
(Some(o), _) => replace_xf(&st.doc, o, natural, !is_text),
(None, Some(r)) => fit(natural, r),
(None, None) => {
let c = at.or_else(|| st.doc.artboards.first().map(|a| a.rect.center())).unwrap_or_default();
@@ -319,6 +345,13 @@ fn place(s: &mut Session, p: &Value) -> Result<Value> {
)
}
/// Placed type's frame without `rect` or Replace: the artboard under `at` (else the first) less a
/// 36 pt margin.
fn text_frame(d: &Document, at: Option<Point>) -> Rect {
let board = at.and_then(|c| d.artboards.iter().find(|a| a.rect.contains(c))).or(d.artboards.first()).map_or(TEXT_FRAME, |a| a.rect);
Rect::from_center_size(board.center(), ((board.width() - 72.0).max(72.0), (board.height() - 72.0).max(72.0)))
}
/// A new locked template layer named after the file, right below the layer `below` (else at the
/// bottom).
fn template_layer(d: &mut Document, below: Option<NodeId>, name: &str) -> Result<NodeId> {
@@ -361,7 +394,7 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
let thumbnail = p.get("thumbnail").and_then(Value::as_f64);
let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]);
for mut q in queued_files(p)? {
for k in ["link", "template", "page", "crop"] {
for k in ["link", "template", "page", "crop", "text"] {
if let Some(v) = p.get(k) {
q[k] = v.clone();
}
+255
View File
@@ -0,0 +1,255 @@
//! Placing a plain-text file (Text Import Options): its bytes decoded by character set and
//! platform, extra returns and spaces cleaned up, the text set as area type.
use serde_json::Value;
use super::*;
/// Text files as `file.place` reports them (not a format `document.open` reads).
pub(super) const FORMAT: fileio::Format = fileio::Format {
id: "txt",
label: "Text",
extensions: fileio::TEXT_EXTS,
mime: "text/plain",
read: true,
write: false,
raster: false,
options: &[],
};
/// The most bytes a text file placed as type may have.
const MAX_TEXT: usize = 4 << 20;
/// Text Import Options (`file.place`'s `text` param).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct TextOptions {
/// Decode as the platform's 8-bit character set (Windows-1252 or Mac Roman) instead of
/// Unicode.
ansi: bool,
/// The file comes from a Mac (its 8-bit character set is Mac Roman).
mac: bool,
/// Join the lines of each block of text (blank lines end paragraphs).
remove_line_returns: bool,
/// Drop the blank lines between paragraphs.
remove_paragraph_returns: bool,
/// Replace runs of this many spaces or more with a tab (2 or more).
spaces_to_tab: Option<usize>,
}
impl TextOptions {
/// The options in `p.text` for command `cmd`.
pub(super) fn parse(p: &Value, cmd: &str) -> Result<Self> {
let Some(t) = p.get("text") else { return Ok(Self::default()) };
if !t.is_object() {
return Err(bad(cmd, "text must be {characterSet?, platform?, removeLineReturns?, removeParagraphReturns?, replaceSpaces?}"));
}
let choice = |key: &str, values: [&str; 2]| match str_param(t, key) {
None => Ok(false),
Some(v) if v == values[0] => Ok(false),
Some(v) if v == values[1] => Ok(true),
Some(v) => Err(bad(cmd, format!("text.{key} `{v}`: use \"{}\" or \"{}\"", values[0], values[1]))),
};
let spaces_to_tab = match t.get("replaceSpaces") {
None | Some(Value::Null) => None,
Some(v) => Some(
v.as_u64().filter(|n| (2..=100).contains(n)).ok_or_else(|| bad(cmd, "text.replaceSpaces must be a whole number from 2 to 100"))?
as usize,
),
};
Ok(Self {
ansi: choice("characterSet", ["unicode", "ansi"])?,
mac: choice("platform", ["windows", "mac"])?,
remove_line_returns: bool_or(t, "removeLineReturns", false),
remove_paragraph_returns: bool_or(t, "removeParagraphReturns", false),
spaces_to_tab,
})
}
}
/// The text of a text file's `bytes`, decoded and cleaned up as `o` says: paragraphs separated by
/// `\n`.
pub(super) fn import(bytes: &[u8], o: TextOptions, cmd: &str) -> Result<String> {
if bytes.len() > MAX_TEXT {
return Err(bad(cmd, format!("the text file is too large to place as type (at most {} MB)", MAX_TEXT >> 20)));
}
Ok(clean(&decode(bytes, o), o))
}
/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark; bytes that aren't UTF-8 are
/// read as the platform's 8-bit set), or the platform's 8-bit character set.
fn decode(bytes: &[u8], o: TextOptions) -> String {
let eight_bit = |b: &[u8]| -> String { b.iter().map(|&c| if o.mac { mac_roman(c) } else { windows_1252(c) }).collect() };
if o.ansi {
return eight_bit(bytes);
}
let utf16 = |rest: &[u8], le: bool| {
let units = rest.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) });
char::decode_utf16(units).map(|c| c.unwrap_or(char::REPLACEMENT_CHARACTER)).collect()
};
match bytes {
[0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(),
[0xFF, 0xFE, rest @ ..] => utf16(rest, true),
[0xFE, 0xFF, rest @ ..] => utf16(rest, false),
_ => std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string),
}
}
/// Byte `b` of Windows-1252.
fn windows_1252(b: u8) -> char {
const HIGH: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', //
'\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜', '™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
];
match b {
0x80..=0x9F => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or(char::REPLACEMENT_CHARACTER),
_ => char::from(b),
}
}
/// Byte `b` of Mac Roman.
fn mac_roman(b: u8) -> char {
const HIGH: [char; 128] = [
'Ä', 'Å', 'Ç', 'É', 'Ñ', 'Ö', 'Ü', 'á', 'à', 'â', 'ä', 'ã', 'å', 'ç', 'é', 'è', //
'ê', 'ë', 'í', 'ì', 'î', 'ï', 'ñ', 'ó', 'ò', 'ô', 'ö', 'õ', 'ú', 'ù', 'û', 'ü', //
'†', '°', '¢', '£', '§', '•', '¶', 'ß', '®', '©', '™', '´', '¨', '≠', 'Æ', 'Ø', //
'∞', '±', '≤', '≥', '¥', 'µ', '∂', '∑', '∏', 'π', '∫', 'ª', 'º', 'Ω', 'æ', 'ø', //
'¿', '¡', '¬', '√', 'ƒ', '≈', '∆', '«', '»', '…', '\u{A0}', 'À', 'Ã', 'Õ', 'Œ', 'œ', //
'–', '—', '“', '”', '‘', '’', '÷', '◊', 'ÿ', 'Ÿ', '⁄', '€', '‹', '›', 'fi', 'fl', //
'‡', '·', '‚', '„', '‰', 'Â', 'Ê', 'Á', 'Ë', 'È', 'Í', 'Î', 'Ï', 'Ì', 'Ó', 'Ô', //
'\u{F8FF}', 'Ò', 'Ú', 'Û', 'Ù', 'ı', 'ˆ', '˜', '¯', '˘', '˙', '˚', '¸', '˝', '˛', 'ˇ',
];
match b {
0x80.. => HIGH.get(usize::from(b - 0x80)).copied().unwrap_or(char::REPLACEMENT_CHARACTER),
_ => char::from(b),
}
}
/// `text` with its line breaks (CR LF, CR or LF) as paragraph breaks, cleaned up as `o` says:
/// with `remove_line_returns` each block of lines is one paragraph (a blank line ends it, and stays
/// as an empty paragraph unless `remove_paragraph_returns`); with only `remove_paragraph_returns`
/// each line is a paragraph and blank lines go. Other control characters than tabs are dropped,
/// and so are blank lines at the end.
fn clean(text: &str, o: TextOptions) -> String {
let text = text.replace("\r\n", "\n").replace('\r', "\n");
let text: String = text.chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
let blank = |l: &str| l.trim().is_empty();
let mut paras: Vec<String> = vec![];
if o.remove_line_returns {
let mut block: Option<String> = None;
for line in text.split('\n') {
if blank(line) {
paras.extend(block.take());
if !o.remove_paragraph_returns {
paras.push(String::new());
}
continue;
}
match &mut block {
Some(b) => {
b.push(' ');
b.push_str(line.trim());
}
None => block = Some(line.trim_end().to_string()),
}
}
paras.extend(block);
} else {
paras = text.split('\n').filter(|l| !(o.remove_paragraph_returns && blank(l))).map(str::to_string).collect();
}
while paras.last().is_some_and(|p| blank(p)) {
paras.pop();
}
let out = paras.join("\n");
match o.spaces_to_tab {
Some(n) => tabs_for_spaces(&out, n),
None => out,
}
}
/// `s` with each run of `n` or more spaces replaced by a tab.
fn tabs_for_spaces(s: &str, n: usize) -> String {
let mut out = String::with_capacity(s.len());
let mut run = 0;
let flush = |out: &mut String, run: usize| {
if run >= n {
out.push('\t');
} else {
out.extend(std::iter::repeat_n(' ', run));
}
};
for c in s.chars() {
if c == ' ' {
run += 1;
continue;
}
flush(&mut out, run);
run = 0;
out.push(c);
}
flush(&mut out, run);
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 {
let mut t = vectorcraft_doc::TextObject::point(Point::ORIGIN, "", vectorcraft_doc::CharStyle::default());
if let Some(run) = t.runs.first_mut() {
run.text = text;
}
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
t.cached_bounds = Some(lay.bounds);
t
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(lines: bool, paras: bool) -> TextOptions {
TextOptions { remove_line_returns: lines, remove_paragraph_returns: paras, ..TextOptions::default() }
}
#[test]
fn crlf_with_returns_removed_gives_one_paragraph_per_block() {
let text = "First line\r\nof one block\r\n\r\nSecond block\r\nends here\r\n\r\n\r\nThird\r\n";
assert_eq!(clean(text, opts(true, true)), "First line of one block\nSecond block ends here\nThird");
assert_eq!(clean(text, opts(true, false)), "First line of one block\n\nSecond block ends here\n\n\nThird");
assert_eq!(clean(text, opts(false, true)), "First line\nof one block\nSecond block\nends here\nThird");
assert_eq!(clean(text, opts(false, false)), "First line\nof one block\n\nSecond block\nends here\n\n\nThird");
assert_eq!(clean("Old\rMac\r\rText", opts(true, true)), "Old Mac\nText", "CR alone breaks lines too");
}
#[test]
fn runs_of_spaces_become_tabs_and_controls_go() {
let o = TextOptions { spaces_to_tab: Some(3), ..TextOptions::default() };
assert_eq!(clean("a b c d\u{0}e\tf", o), "a b\tc\tde\tf");
}
#[test]
fn decodes_by_character_set_and_platform() {
let unicode = TextOptions::default();
assert_eq!(decode("café".as_bytes(), unicode), "café");
assert_eq!(decode(&[0xEF, 0xBB, 0xBF, b'h', b'i'], unicode), "hi", "UTF-8 byte-order mark");
assert_eq!(decode(&[0xFF, 0xFE, b'h', 0, 0xE9, 0], unicode), "hé", "UTF-16 LE");
assert_eq!(decode(&[0xFE, 0xFF, 0, b'h', 0x20, 0xAC], unicode), "h€", "UTF-16 BE");
assert_eq!(decode(&[b'c', b'a', b'f', 0xE9, 0x80], unicode), "café€", "not UTF-8: Windows-1252");
let ansi = TextOptions { ansi: true, ..TextOptions::default() };
assert_eq!(decode(&[0x93, b'q', 0x94, 0xE9], ansi), "“q”é");
let mac = TextOptions { ansi: true, mac: true, ..TextOptions::default() };
assert_eq!(decode(&[0xD2, b'q', 0xD3, 0x8E, 0xA5, 0xDB], mac), "“q”é•€");
}
#[test]
fn options_parse_and_reject_bad_values() {
let p = serde_json::json!({"text": {"characterSet": "ansi", "platform": "mac", "removeLineReturns": true, "replaceSpaces": 4}});
let o = TextOptions::parse(&p, "file.place").unwrap();
assert_eq!(o, TextOptions { ansi: true, mac: true, remove_line_returns: true, remove_paragraph_returns: false, spaces_to_tab: Some(4) });
assert_eq!(TextOptions::parse(&serde_json::json!({}), "file.place").unwrap(), TextOptions::default());
for bad in
[serde_json::json!({"text": 3}), serde_json::json!({"text": {"platform": "amiga"}}), serde_json::json!({"text": {"replaceSpaces": 1}})]
{
assert!(TextOptions::parse(&bad, "file.place").is_err(), "{bad}");
}
}
}