diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 66f27724..67eb877c 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -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 { + 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 { - 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 { - 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`). diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index a781fec9..c6d1e1a1 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -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 { vec![ @@ -42,7 +47,7 @@ pub fn specs() -> Vec { "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 { "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 { /// 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, @@ -112,6 +119,13 @@ fn pt_per_px(ppi: Option<(f64, f64)>) -> (f64, f64) { fn load(p: &Value, cmd: &str) -> Result { 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) -> 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> = nodes.iter().map(|n| Arc::new(d.reid(n))).collect(); @@ -238,10 +253,10 @@ fn rect_param(p: &Value) -> Result> { } /// 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 { (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 { ) } +/// 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) -> 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, name: &str) -> Result { @@ -361,7 +394,7 @@ fn queue(s: &mut Session, p: &Value) -> Result { 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(); } diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs new file mode 100644 index 00000000..26be4029 --- /dev/null +++ b/crates/engine/src/cmd/place/text.rs @@ -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, +} + +impl TextOptions { + /// The options in `p.text` for command `cmd`. + pub(super) fn parse(p: &Value, cmd: &str) -> Result { + 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 { + 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 = vec![]; + if o.remove_line_returns { + let mut block: Option = 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}"); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ddbb686c..efd5b445 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1031,6 +1031,8 @@ mod tests_targeting; #[cfg(test)] mod tests_textedit; #[cfg(test)] +mod tests_textimport; +#[cfg(test)] mod tests_tileedge; #[cfg(test)] mod tests_tints; diff --git a/crates/engine/src/tests_place.rs b/crates/engine/src/tests_place.rs index dfa85e46..aba974e5 100644 --- a/crates/engine/src/tests_place.rs +++ b/crates/engine/src/tests_place.rs @@ -301,7 +301,7 @@ fn bad_place_params_are_errors() { for p in [ json!({}), json!({"name": "x.png", "dataBase64": "%%%"}), - json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(b"hello")}), + json!({"name": "notes.xyz", "dataBase64": vectorcraft_format::base64_encode(b"hello")}), json!({"name": "x.png", "dataBase64": ok, "rect": [0, 0, 0, 10]}), json!({"name": "x.png", "dataBase64": ok, "at": [1e12, 0]}), json!({"name": "x.png", "dataBase64": ok, "page": 0}), diff --git a/crates/engine/src/tests_textimport.rs b/crates/engine/src/tests_textimport.rs new file mode 100644 index 00000000..7aee8df1 --- /dev/null +++ b/crates/engine/src/tests_textimport.rs @@ -0,0 +1,85 @@ +//! File → Place of a text file: Text Import Options, and 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:?}"); +} + +#[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()); + 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"); +} diff --git a/crates/engine/tests/text_import_fuzz.rs b/crates/engine/tests/text_import_fuzz.rs new file mode 100644 index 00000000..536e71eb --- /dev/null +++ b/crates/engine/tests/text_import_fuzz.rs @@ -0,0 +1,42 @@ +//! 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. +//! +//! `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). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use proptest::prelude::*; +use serde_json::json; +use vectorcraft_testkit::catch_quiet; + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, failure_persistence: None, ..ProptestConfig::default() })] + + #[test] + fn any_bytes_place_as_text_without_panics( + bytes in prop::collection::vec(prop_oneof![any::(), Just(b'\r'), Just(b'\n'), Just(b' ')], 0..400), + bom in prop::sample::select(vec![&b""[..], b"\xEF\xBB\xBF", b"\xFF\xFE", b"\xFE\xFF"]), + ansi in any::(), + mac in any::(), + lines in any::(), + paras in any::(), + spaces in prop::option::of(0u64..8), + ) { + let data = [bom, &bytes[..]].concat(); + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let text = json!({ + "characterSet": if ansi { "ansi" } else { "unicode" }, + "platform": if mac { "mac" } else { "windows" }, + "removeLineReturns": lines, + "removeParagraphReturns": paras, + "replaceSpaces": spaces, + }); + let p = json!({"name": "fuzz.txt", "dataBase64": vectorcraft_format::base64_encode(&data), "text": text, "thumbnail": 8}); + let _ = s.execute("file.place.info", &p); + let _ = s.execute("file.place", &p); + }); + prop_assert!(r.is_ok(), "panicked: {:?}", r.err()); + } +} diff --git a/crates/ui-egui/src/dialogs/document_setup.rs b/crates/ui-egui/src/dialogs/document_setup.rs index 6cead6d9..f5e6c8fa 100644 --- a/crates/ui-egui/src/dialogs/document_setup.rs +++ b/crates/ui-egui/src/dialogs/document_setup.rs @@ -61,14 +61,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// A dropdown row bound to the string `d.fields[key]`; `options` are (value, label). pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, options: &[(&str, &str)]) { - let cur = d.str(key); - let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l); - let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect(); - widgets::label_row(ui, label, LABEL, |ui| { - if let Some((value, _)) = widgets::dropdown(ui, key, shown, &labels, 220.0).and_then(|i| options.get(i)) { - d.fields.insert(key.into(), json!(value)); - } - }); + form::choice(ui, d, key, label, (LABEL, 220.0), options); } /// A checkbox bound to `d.fields[key]`. diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index 88f49c96..1bbac292 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -101,6 +101,19 @@ pub(super) fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { } } +/// A dropdown row bound to the string `d.fields[key]`: `label` in a column `widths.0` wide, the +/// dropdown `widths.1` wide; `options` are (value, label). +pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str, widths: (f32, f32), options: &[(&str, &str)]) { + let cur = d.str(key); + let shown = options.iter().find(|(v, _)| v.eq_ignore_ascii_case(&cur)).map_or(cur.as_str(), |(_, l)| l); + let labels: Vec<&str> = options.iter().map(|(_, l)| *l).collect(); + crate::widgets::label_row(ui, label, widths.0, |ui| { + if let Some((value, _)) = crate::widgets::dropdown(ui, key, shown, &labels, widths.1).and_then(|i| options.get(i)) { + d.fields.insert(key.into(), json!(value)); + } + }); +} + /// The generic dialog body: a field per non-boolean value (positions and indices hidden), the /// distances in `unit`. pub(super) fn grid(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) { diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index d550f0f8..b971a13b 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -46,6 +46,7 @@ pub mod spot_colors; pub(crate) mod svg_options; pub mod swatch_conflict; pub mod swatch_options; +pub mod text_import; pub mod tile_edge_color; mod tools; mod transform; @@ -196,6 +197,7 @@ registry! { FileInfo: [file_info::KIND] => file_info::SPEC, RasterEffectsSettings: [raster_effects::KIND] => raster_effects::SPEC, MissingLinks: [missing_links::KIND] => missing_links::SPEC, + TextImport: [text_import::KIND] => text_import::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/text_import.rs b/crates/ui-egui/src/dialogs/text_import.rs new file mode 100644 index 00000000..e4f159b0 --- /dev/null +++ b/crates/ui-egui/src/dialogs/text_import.rs @@ -0,0 +1,142 @@ +//! Text Import Options: placing a text file asks how to read it (platform and character set) and +//! what to clean up (line breaks inside paragraphs, blank lines between them, runs of spaces). OK +//! places it as area type (`file.place` with `text`). +//! +//! Fields: `platform` (`windows` | `mac`), `characterSet` (`unicode` | `ansi`), +//! `removeLineReturns`, `removeParagraphReturns`, `replaceSpaces` (on or off), `spaces` (how many +//! spaces make a tab, 2–100), and `__params` (the `file.place` params naming the file). + +use serde_json::{Value, json}; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Text Import Options. +pub const KIND: &str = "textImport"; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Text Import Options".into(), body, confirm, min_width: 340.0, ..DialogSpec::FORM }; + +/// The label column of the Encoding rows. +const LABEL: f32 = 104.0; + +/// Is the file `p` names (`path` or `name`) a text file? +pub fn is_text(p: &Value) -> bool { + let name = p.get("path").or_else(|| p.get("name")).and_then(Value::as_str).unwrap_or_default(); + vectorcraft_engine::cmd::fileio::TEXT_EXTS.contains(&vectorcraft_engine::cmd::fileio::extension(name).as_str()) +} + +/// Ask how to place the text file `params` (`file.place` params) names. +pub fn open(app: &mut VectorcraftApp, params: &Value) -> Value { + let fields = json!({ + "platform": if cfg!(target_os = "macos") { "mac" } else { "windows" }, + "characterSet": "unicode", + "removeLineReturns": false, + "removeParagraphReturns": false, + "replaceSpaces": false, + "spaces": 3, + "__params": params, + }); + app.ui.dialog = Some(Dialog::new(KIND, fields)); + json!({ "dialog": KIND }) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + widgets::subheader(ui, "Encoding"); + form::choice(ui, d, "platform", "Platform:", (LABEL, 180.0), &[("windows", "Windows"), ("mac", "Mac")]); + form::choice(ui, d, "characterSet", "Character Set:", (LABEL, 180.0), &[("unicode", "Unicode"), ("ansi", "ANSI")]); + ui.add_space(10.0); + widgets::subheader(ui, "Line Breaks"); + for (key, label) in + [("removeLineReturns", "Join the lines of each paragraph"), ("removeParagraphReturns", "Remove blank lines between paragraphs")] + { + let on = d.bool(key); + if widgets::check(ui, label, on, true) { + d.fields.insert(key.into(), json!(!on)); + } + } + ui.add_space(10.0); + widgets::subheader(ui, "Spaces"); + let on = d.bool("replaceSpaces"); + ui.horizontal(|ui| { + if widgets::check(ui, "Replace", on, true) { + d.fields.insert("replaceSpaces".into(), json!(!on)); + } + ui.add_enabled_ui(on, |ui| { + if let Some(n) = widgets::plain_field(ui, "text-import-spaces", d.f64("spaces", 3.0), "", 0, 44.0) { + d.fields.insert("spaces".into(), json!(n.round().clamp(2.0, 100.0))); + } + widgets::dim_label(ui, "or more spaces with a tab"); + }); + }); + false +} + +/// `file.place`'s `text` options from the fields. +fn options(d: &Dialog) -> Value { + let mut o = json!({ + "platform": d.str("platform"), + "characterSet": d.str("characterSet"), + "removeLineReturns": d.bool("removeLineReturns"), + "removeParagraphReturns": d.bool("removeParagraphReturns"), + }); + if d.bool("replaceSpaces") { + o["replaceSpaces"] = json!(d.f64("spaces", 3.0).round().clamp(2.0, 100.0) as u64); + } + o +} + +/// Places the file with the options; closes first, so an error shows in the status bar. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let mut params = d.fields.get("__params").cloned().unwrap_or_else(|| json!({})); + params["text"] = options(d); + app.ui.dialog = None; + crate::place::run(app, ¶ms) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + use vectorcraft_engine::doc::NodeKind; + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + #[test] + fn placing_a_text_file_asks_for_the_import_options_then_places_area_type() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let text = b"one\r\ntwo\r\n\r\nthree\r\n"; + let file = json!({"name": "notes.txt", "dataBase64": vectorcraft_format::base64_encode(text)}); + let r = app.run("file.place", file).unwrap(); + assert_eq!(r["dialog"], KIND); + let text = frame(&mut app); + for label in [ + "Text Import Options", + "Encoding", + "Platform:", + "Character Set:", + "Unicode", + "Line Breaks", + "Join the lines of each paragraph", + "Spaces", + "OK", + "Cancel", + ] { + assert!(text.contains(label), "{label} in {text}"); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("removeLineReturns".into(), json!(true)); + d.fields.insert("removeParagraphReturns".into(), json!(true)); + assert_eq!(options(d)["removeLineReturns"], true); + let r = super::super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + let st = app.session.doc().unwrap(); + let n = st.doc.node(vectorcraft_engine::doc::NodeId(r["ids"][0].as_u64().unwrap())).unwrap(); + let NodeKind::Text(t) = &n.kind else { panic!("type") }; + assert_eq!(t.plain_text(), "one two\nthree", "one paragraph per block"); + assert_eq!(st.history.undo.len(), 1); + } +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 4261c67f..8a87f959 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -319,7 +319,7 @@ mod tests { place_bytes(&mut app, "tiny.webp", &webp).unwrap(); let placed = app.session.doc().unwrap().selection.objects[0]; assert_eq!(image_size(&app, placed), (3, 2)); - assert!(place_bytes(&mut app, "x.txt", b"hello").is_err()); + assert!(place_bytes(&mut app, "x.xyz", b"hello").is_err()); } #[test] diff --git a/crates/ui-egui/src/place.rs b/crates/ui-egui/src/place.rs index 40815f9d..5cb75932 100644 --- a/crates/ui-egui/src/place.rs +++ b/crates/ui-egui/src/place.rs @@ -67,9 +67,13 @@ fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result Result { + if p.get("text").is_none() && crate::dialogs::text_import::is_text(p) { + return Ok(crate::dialogs::text_import::open(app, p)); + } if p.get("path").is_none() && p.get("dataBase64").is_none() { let files: Vec = match p.get("paths").and_then(Value::as_array) { Some(a) => a.iter().filter_map(Value::as_str).map(|path| json!({ "path": path })).collect(), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 8301c122..3d891ee1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -262,3 +262,8 @@ folder; `ui.dialog.set {field: "discard", value: true}` then confirm ignores it `applyToAll` the rest too. The next missing file is asked about after each answer; `ui.dialog.cancel` stops asking. Then, with the preference `updateLinks: "askWhenModified"`, modified linked files are offered for update in a `confirm` dialog whose `ui.dialog.confirm` runs `links.update`. + +Text Import Options: placing a `.txt` file through the app (`file.place` with a file and no `text` options, the Place +dialog, a drop) opens the `textImport` dialog (fields `platform`: `windows`/`mac`, `characterSet`: `unicode`/`ansi`, +`removeLineReturns`, `removeParagraphReturns`, `replaceSpaces`, `spaces`). `ui.dialog.confirm` places the file as area +type with those options (`file.place {text: {…}}`). diff --git a/docs/mcp.md b/docs/mcp.md index 569bcfc4..21bbea0e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -940,3 +940,14 @@ file system (the web), linked images show their previews. {"name":"run_command","arguments":{"command":"links.relink","params":{"ids":[12],"path":"/new/photo.png"}}} {"name":"run_command","arguments":{"command":"links.update","params":{}}} ``` + +Placing a text file (`.txt`) sets it as area type: `file.place {path | name+dataBase64, text?: {characterSet?: +"unicode" | "ansi", platform?: "windows" | "mac", removeLineReturns?, removeParagraphReturns?, replaceSpaces?: n}}` +(Text Import Options). Unicode reads UTF-8, or UTF-16 with a byte-order mark; ANSI reads Windows-1252 (Mac Roman on +`mac`). `removeLineReturns` joins the lines of each block into one paragraph, `removeParagraphReturns` drops blank +lines, `replaceSpaces: 3` turns runs of 3 or more spaces into tabs. The frame fills `rect`, the replaced object's +bounds, or the artboard less a 36 pt margin. + +```json +{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/notes.txt","text":{"removeLineReturns":true,"removeParagraphReturns":true}}}} +```