diff --git a/Cargo.lock b/Cargo.lock index 934be1b12..248679aac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -705,6 +705,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f13298d9178622b7916ec5d01d470425c04273c8ed07e87b1c38b34c4f4612b" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -6205,6 +6216,7 @@ dependencies = [ name = "vectorcraft-engine" version = "0.19.0" dependencies = [ + "cfb", "craft-docpath", "crc32fast", "flate2", diff --git a/ROADMAP.md b/ROADMAP.md index 0206168ac..cbce1fbfa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Document Tools add-on's New Symbols from Layers; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Progress log row for the Arrange Tools add-on's Sort Objects; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -129,6 +129,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap ## Progress log +- 2026-11-01: Randomize Opacity joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): each selected object gets its own opacity at random between a minimum and a maximum percent, with a seed that repeats the result, as one undo step (`addon.arrange.randomizeOpacity`, #4925). +- 2026-11-01: Sort Objects joins the Arrange Tools add-on (Object › Plug-ins › Arrange Tools): the selected objects are sorted by width, height, area, position, opacity or stacking order (small to large, large to small or at random) and take another of those in that order, trading their values or stepping them evenly, as one undo step (`addon.arrange.sort`, #4917). - 2026-11-01: New Symbols from Layers joins the Document Tools add-on (Object › Plug-ins › Document Tools): one symbol of each top-level layer's art (the layers highlighted in the Layers panel when two or more are, else every layer), named after the layer, made as New Symbol makes one; Convert Layers to Symbols also puts an instance of each in place of its layer's art. Empty layers are skipped and reported; one undo step. A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Document Tools, a built-in add-on (Object › Plug-ins › Document Tools), starts with Save a Timestamped Copy: a copy of the document named `_YYYY-MM-DD_HH-MM` (UTC; `-2`, `-3`… when that name is taken) goes next to it, or into a folder picked for a document never saved, written as File › Save a Copy writes it, so the document keeps its path, title and modified state. Off on the web, which has no clock. A VectorCraft addition: headline numbers unchanged. - 2026-11-01: Divide into Dashes joins the Path Precision Tools add-on (Object › Plug-ins › Path Precision Tools): the selected paths, or only their direct-selected segments, are cut into separate open paths of a dash length measured along the curve, with an optional gap (left empty, or kept as pieces), each keeping the path's appearance; Fit to Corners and Ends stretches or squeezes the lengths so every run starts and ends on a dash. A dialog with Preview, one undo step, at most 100000 pieces. A VectorCraft addition: headline numbers unchanged. @@ -208,6 +210,8 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap | Date | Change | Summary | |---|---|---| +| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Randomize Opacity (#4925); headline numbers unchanged. | +| 2026-11-01 | minor | Progress log row for the Arrange Tools add-on's Sort Objects (#4917); headline numbers unchanged. | | 2026-11-01 | minor | Progress log row for the Document Tools add-on's New Symbols from Layers (#4927); headline numbers unchanged. | | 2026-11-01 | minor | Progress log row for the Document Tools add-on's Save a Timestamped Copy (#4926); headline numbers unchanged. | | 2026-11-01 | minor | Progress log row for Divide into Dashes (Path Precision Tools add-on, #4950); headline numbers unchanged. | diff --git a/crates/doc/src/setup.rs b/crates/doc/src/setup.rs index 62276ece9..c26918153 100644 --- a/crates/doc/src/setup.rs +++ b/crates/doc/src/setup.rs @@ -164,6 +164,14 @@ pub const LANGUAGES: &[(&str, &str, Quotes)] = &[ ("Polish", "pl", Quotes { double: ['„', '”'], single: ['‚', '’'] }), ("Czech", "cs", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), ("Russian", "ru", Quotes { double: ['«', '»'], single: ['„', '“'] }), + // „…” outside, ’…’ inside: Pravopis srpskoga jezika (Matica srpska, 2010, ISBN + // 978-86-7946-079-0, т. 168, pp. 133–135) and CLDR + // (https://github.com/unicode-org/cldr/blob/main/common/main/sr.xml, `delimiters`). + ("Serbian: Cyrillic", "sr-Cyrl", Quotes { double: ['„', '”'], single: ['’', '’'] }), + ("Serbian: Latin", "sr-Latn", Quotes { double: ['„', '”'], single: ['’', '’'] }), + // „…“ outside, ‚…‘ inside: CLDR (https://github.com/unicode-org/cldr/blob/main/common/main/hr.xml, + // `delimiters`). + ("Croatian", "hr", Quotes { double: ['„', '“'], single: ['‚', '‘'] }), ("Turkish", "tr", Quotes { double: ['“', '”'], single: ['‘', '’'] }), ("Japanese", "ja", Quotes { double: ['「', '」'], single: ['『', '』'] }), ("Chinese", "zh", Quotes { double: ['“', '”'], single: ['‘', '’'] }), @@ -373,12 +381,19 @@ mod tests { (char_language_name(None), char_language_name(Some("gu")), char_language_name(Some("x-unknown"))), (Some("English: USA"), Some("Gujarati"), None) ); + // Serbian in either script, and Croatian. + assert_eq!(char_language("Serbian: Cyrillic"), Ok(Some("sr-Cyrl"))); + assert_eq!(char_language("sr-latn"), Ok(Some("sr-Latn"))); + assert_eq!(char_language("Croatian"), Ok(Some("hr"))); + assert_eq!(char_language_name(Some("sr-Cyrl")), Some("Serbian: Cyrillic")); // Every entry has a tag of its own, and a name of its own. for (i, (n, t, _)) in LANGUAGES.iter().enumerate() { assert!(LANGUAGES.iter().skip(i + 1).all(|(n2, t2, _)| n2 != n && t2 != t), "{n} {t}"); } } + /// Straight quotes become the language's: opening or closing by what comes before, inner + /// quotes as single ones, and the context carried across pieces converted apart. #[test] fn quotes_open_and_close_by_context() { let mut prev = None; @@ -387,6 +402,13 @@ mod tests { let mut prev = Some('('); assert_eq!(german.apply("\"a\"", &mut prev), "„a“"); assert_eq!(prev, Some('“')); + // Serbian closes high (”), with ’ on both sides of an inner quote; Croatian as German. + for name in ["Serbian: Cyrillic", "Serbian: Latin"] { + let mut prev = None; + assert_eq!(language_quotes(name).unwrap().apply("\"a 'b' c\"", &mut prev), "„a ’b’ c”", "{name}"); + } + let mut prev = None; + assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“"); // Converting in pieces keeps the context. let mut prev = None; let a = Quotes::ENGLISH.apply("\"", &mut prev); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 7b1199277..9d4ad7399 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -40,6 +40,8 @@ hayro-jpeg2000 = { version = "=0.3.5", default-features = false, features = ["st flate2 = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). roxmltree = "0.21" +# The compound file that holds a Word 97-2003 document placed as type (place/doc.rs). +cfb = "0.15" kurbo = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 986016213..9b11a7115 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -3,23 +3,28 @@ //! Replace with Top Object puts a copy of the topmost selected object in place of each of the //! others; and Set Size of Each gives every selected object an exact width and height. Places and //! sizes are measured and set as `object.setBounds` does. Group Overlapping Objects groups the -//! objects whose visual bounds overlap. Its submenu (Object › Plug-ins › Arrange -//! Tools), its dialogs and Zoom to Selection (a view command) are the UI's. +//! objects whose visual bounds overlap, Randomize Opacity gives each selected object a random +//! opacity, and Sort Objects sorts the selected objects by one attribute and gives them another in +//! that order. Its submenu (Object › Plug-ins › Arrange Tools), its dialogs and Zoom to Selection +//! (a view command) are the UI's. #[cfg(test)] mod tests; +use std::collections::HashMap; use std::sync::Arc; use serde_json::{Value, json}; +use vectorcraft_color::recolor::Rng; use vectorcraft_doc::{Node, NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect}; use vectorcraft_render::effects; use super::super::edit::selected_roots; +use super::super::menucmds::whole_or; use super::super::object::{choice, object_bounds_transform, scaling, transform_node, transform_patterns}; use super::super::typecmd::refresh_bounds_of; -use super::super::{CommandSpec, bad, cmd, has_doc, has_multi, has_selection, number_or, switch_or}; +use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_multi, has_selection, number_or, switch_or}; use crate::{EngineError, Result, Session}; /// The largest gap, width or height (pt) the commands take. @@ -28,8 +33,10 @@ const LIMIT: f64 = 1e6; const LEAST: f64 = 0.001; /// The most columns Arrange in Grid takes. const MAX_COLUMNS: u64 = 10_000; -/// The most objects Group Overlapping Objects looks at. +/// The most objects Group Overlapping Objects and Sort Objects look at. const MAX_OBJECTS: usize = 20_000; +/// The attributes Sort Objects sorts by and gives (`addon.arrange.sort`). +pub const SORT_KEYS: [&str; 7] = ["width", "height", "area", "x", "y", "opacity", "stacking"]; pub fn specs() -> Vec { vec![ @@ -78,6 +85,24 @@ pub fn specs() -> Vec { has_multi, group_overlapping ), + cmd!( + "addon.arrange.randomizeOpacity", + "Randomize Opacity", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{min?: % 0..100 (30), max?: % 0..100 (100; not less than min), seed?: whole number (0; the same seed, objects and range give the same opacities), ids?: [id…] (default: the selection)} give each selected object (the top-level ones) its own opacity, picked at random and evenly from min to max, as one undo step; it is the object's opacity, as the Transparency panel sets it → {count}", + has_selection, + randomize_opacity + ), + cmd!( + "addon.arrange.sort", + "Sort Objects", + ["Object", "Plug-ins", "Arrange Tools"], + None, + "{by?: \"width\" | \"height\" | \"area\" (width × height) | \"x\" (left edge) | \"y\" (top edge) | \"opacity\" (%) | \"stacking\" (stacking order, from the back) (\"area\"), apply?: one of the same (\"stacking\"), order?: \"ascending\" (small to large) | \"descending\" (large to small) | \"random\" (\"ascending\"), distribute?: false (true: the values step evenly from the smallest to the largest; false: the objects trade the values they have), seed?: whole number (0; the order \"random\" picks), scaleStrokes?: the Scale Strokes & Effects preference (width, height and area), ids?: [id…] (default: the selection)} sort the selected objects (the top-level ones, at most 20000) by the attribute `by` and give them the values of `apply` in that order, as one undo step: the first in order gets the smallest value. Sizes and places are measured and set as object.setBounds measures them (Use Preview Bounds), a width, height or area about each object's centre (an area in proportion); stacking order always trades places, and only among the objects in the same layer or group. A run that changes nothing makes no step → {count}", + has_multi, + sort + ), ] } @@ -356,3 +381,155 @@ fn group_overlapping(s: &mut Session, _: &Value) -> Result { })?; Ok(json!({ "groups": groups.iter().map(|i| i.0).collect::>(), "grouped": grouped.len() })) } + +fn randomize_opacity(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.randomizeOpacity"; + let min = number_in(p, "min", 30.0, (0.0, 100.0), C)?; + let max = number_in(p, "max", 100.0, (0.0, 100.0), C)?; + if min > max { + return Err(bad(C, "`min` is greater than `max`")); + } + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let ids = checked_ids_or_selection(s, p, C)?; + if ids.is_empty() { + return Ok(json!({ "count": 0 })); + } + let (lo, hi) = ((min / 100.0) as f32, (max / 100.0) as f32); + s.edit("Randomize Opacity", |d, _| { + for id in &ids { + let o = rng.range(lo, hi); + if let Some(n) = d.node_mut(*id) { + n.opacity = o; + } + } + Ok(()) + })?; + Ok(json!({ "count": ids.len() })) +} + +/// An object Sort Objects sorts: its layer or group, its place in it, its box as +/// `object.setBounds` measures it and its opacity (%). +struct Sorted { + id: NodeId, + parent: Option, + slot: usize, + b: OrientedBox, + opacity: f64, +} + +/// Attribute `key` (one of [`SORT_KEYS`]) of `o`, the `i`th of the objects in stacking order. +fn attribute(key: &str, o: &Sorted, i: usize) -> f64 { + let r = o.b.rect; + // The top-left corner where `object.setBounds` places it (reference point 0). + let corner = o.b.to_doc() * Point::new(r.x0, r.y0); + match key { + "width" => r.width(), + "height" => r.height(), + "area" => r.width() * r.height(), + "x" => corner.x, + "y" => corner.y, + "opacity" => o.opacity, + _ => i as f64, + } +} + +fn sort(s: &mut Session, p: &Value) -> Result { + const C: &str = "addon.arrange.sort"; + let by = choice(p, C, "by", &SORT_KEYS)?.unwrap_or("area"); + let apply = choice(p, C, "apply", &SORT_KEYS)?.unwrap_or("stacking"); + let order = choice(p, C, "order", &["ascending", "descending", "random"])?.unwrap_or("ascending"); + let distribute = switch_or(p, "distribute", false, C)?; + let mut rng = Rng::new(whole_or(p, "seed", 0, C)?); + let strokes = switch_or(p, "scaleStrokes", s.prefs.scale_strokes, C)?; + let ids = checked_ids_or_selection(s, p, C)?; + if ids.len() > MAX_OBJECTS { + return Err(bad(C, "too many objects selected (at most 20000)")); + } + let d = &s.doc()?.doc; + // The objects in stacking order, from the back (their index paths, found in one walk). + let paths = d.index_paths(ids.iter().copied()); + let mut placed: Vec<(&Vec, NodeId)> = ids.iter().filter_map(|id| Some((paths.get(id)?, *id))).collect(); + placed.sort_by(|a, b| a.0.cmp(b.0)); + let items: Vec = placed + .into_iter() + .filter_map(|(path, id)| { + let (&slot, up) = path.split_last()?; + let parent = if up.is_empty() { None } else { Some(d.node_at(up)?.id) }; + Some(Sorted { id, parent, slot, b: s.transform_box(&[id])?, opacity: f64::from(d.node(id)?.opacity) * 100.0 }) + }) + .collect(); + let keys: Vec = items.iter().enumerate().map(|(i, o)| attribute(by, o, i)).collect(); + // The objects in sorted order; equal keys keep their stacking order. + let mut rank: Vec = (0..items.len()).collect(); + match order { + "random" => rng.shuffle(&mut rank), + "descending" => rank.sort_by(|a, b| keys[*b].total_cmp(&keys[*a])), + _ => rank.sort_by(|a, b| keys[*a].total_cmp(&keys[*b])), + } + let count = json!({ "count": items.len() }); + if apply == "stacking" { + // Each layer or group's objects take its objects' places, from the back, in sorted order. + let mut groups: HashMap, Vec> = HashMap::new(); + for o in rank.iter().filter_map(|i| items.get(*i)) { + groups.entry(o.parent).or_default().push(o.slot); + } + if groups.values().all(|slots| slots.is_sorted()) { + return Ok(count); + } + s.edit("Sort Objects", |d, _| { + for (parent, from) in groups { + let ch = d.children_mut(parent)?; + let nodes: Vec> = from.iter().filter_map(|i| ch.get(*i).cloned()).collect(); + let mut to = from; + to.sort_unstable(); + for (slot, n) in to.into_iter().zip(nodes) { + if let Some(c) = ch.get_mut(slot) { + *c = n; + } + } + } + Ok(()) + })?; + return Ok(count); + } + // The values the objects get, smallest first: theirs, or evenly from the smallest to the largest. + let mut values: Vec = items.iter().enumerate().map(|(i, o)| attribute(apply, o, i)).collect(); + values.sort_by(f64::total_cmp); + if let (true, Some(&lo), Some(&hi)) = (distribute && values.len() > 1, values.first(), values.last()) { + let steps = (values.len() - 1) as f64; + values = (0..values.len()).map(|j| lo + (hi - lo) * j as f64 / steps).collect(); + } + let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect(); + if apply == "opacity" { + if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) { + return Ok(count); + } + s.edit("Sort Objects", |d, _| { + for (o, v) in &wanted { + if let Some(n) = d.node_mut(o.id) { + n.opacity = (v / 100.0) as f32; + } + } + Ok(()) + })?; + return Ok(count); + } + let mut xfs = Vec::with_capacity(wanted.len()); + for (o, v) in wanted { + let q = match apply { + "x" => json!({ "x": v, "reference": 0 }), + "y" => json!({ "y": v, "reference": 0 }), + "width" => json!({ "width": v, "strokes": strokes }), + "height" => json!({ "height": v, "strokes": strokes }), + // An area: both sides in proportion; an object without one keeps its size. + _ => { + let (w, h) = (o.b.rect.width(), o.b.rect.height()); + let k = if w * h > 1e-12 { (v / (w * h)).sqrt() } else { 1.0 }; + json!({ "width": w * k, "height": h * k, "strokes": strokes }) + } + }; + xfs.push((o.id, object_bounds_transform(s, &q, o.id)?)); + } + transform_each(s, "Sort Objects", strokes, &xfs)?; + Ok(count) +} diff --git a/crates/engine/src/cmd/addons/arrange/tests.rs b/crates/engine/src/cmd/addons/arrange/tests.rs index 48f9ae4ec..85191935b 100644 --- a/crates/engine/src/cmd/addons/arrange/tests.rs +++ b/crates/engine/src/cmd/addons/arrange/tests.rs @@ -256,7 +256,15 @@ fn off_without_a_selection() { let mut s = session(); rect(&mut s, 0.0, 0.0, 20.0, 10.0); s.execute("select.none", &json!({})).unwrap(); - for c in ["addon.arrange.grid", "addon.arrange.swap", "addon.arrange.replace", "addon.arrange.size", "addon.arrange.groupOverlapping"] { + for c in [ + "addon.arrange.grid", + "addon.arrange.swap", + "addon.arrange.replace", + "addon.arrange.size", + "addon.arrange.groupOverlapping", + "addon.arrange.randomizeOpacity", + "addon.arrange.sort", + ] { assert!(!enabled(&s, c), "{c}"); } assert!(!enabled(&Session::new(), "addon.arrange.swap")); @@ -336,3 +344,221 @@ fn group_overlapping_is_off_with_one_object_and_scales_to_many() { assert_eq!(sets.len(), 2000); assert!(sets.iter().all(|s| s.len() == 2 && s[1] == s[0] + 1)); } + +fn opacity(s: &Session, id: NodeId) -> f32 { + s.doc().unwrap().doc.node(id).unwrap().opacity +} + +#[test] +fn randomize_opacity_gives_each_object_its_own_opacity_within_the_range() { + let mut s = session(); + let ids: Vec = (0..20).map(|i| rect(&mut s, i as f64 * 10.0, 0.0, 5.0, 5.0)).collect(); + select(&mut s, &ids); + let n = steps(&s); + let r = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 7})).unwrap(); + assert_eq!(r["count"], json!(20)); + assert_eq!(steps(&s), n + 1, "one undo step"); + let first: Vec = ids.iter().map(|id| opacity(&s, *id)).collect(); + assert!(first.iter().all(|o| (0.3..=0.6).contains(o)), "{first:?}"); + assert!(first.windows(2).any(|w| w[0] != w[1]), "each its own: {first:?}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(ids.iter().all(|id| opacity(&s, *id) == 1.0), "undone"); + // The same seed repeats them; another seed doesn't. + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 7})).unwrap(); + assert_eq!(ids.iter().map(|id| opacity(&s, *id)).collect::>(), first); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 30, "max": 60, "seed": 8})).unwrap(); + assert_ne!(ids.iter().map(|id| opacity(&s, *id)).collect::>(), first); + // Defaults 30 to 100; min = max gives every object that opacity. + s.execute("addon.arrange.randomizeOpacity", &json!({})).unwrap(); + assert!(ids.iter().all(|id| (0.3..=1.0).contains(&opacity(&s, *id)))); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 45, "max": 45})).unwrap(); + assert!(ids.iter().all(|id| opacity(&s, *id) == 0.45)); +} + +#[test] +fn randomize_opacity_takes_ids_and_leaves_the_rest() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 5.0, 5.0); + let b = rect(&mut s, 10.0, 0.0, 5.0, 5.0); + select(&mut s, &[a, b]); + let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + let c = rect(&mut s, 20.0, 0.0, 5.0, 5.0); + s.execute("select.none", &json!({})).unwrap(); + assert!(!enabled(&s, "addon.arrange.randomizeOpacity")); + s.execute("addon.arrange.randomizeOpacity", &json!({"ids": [c.0], "min": 10, "max": 10})).unwrap(); + assert_eq!((opacity(&s, a), opacity(&s, b), opacity(&s, c)), (1.0, 1.0, 0.1)); + // A group takes one opacity of its own; the objects inside keep theirs. + select(&mut s, &[g]); + s.execute("addon.arrange.randomizeOpacity", &json!({"min": 20, "max": 20})).unwrap(); + assert_eq!((opacity(&s, g), opacity(&s, a), opacity(&s, b)), (0.2, 1.0, 1.0)); +} + +#[test] +fn randomize_opacity_refuses_bad_params() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 20.0, 10.0); + select(&mut s, &[a]); + refuses( + &mut s, + "addon.arrange.randomizeOpacity", + &[ + json!({"min": -1}), + json!({"max": 101}), + json!({"min": 80, "max": 20}), + json!({"min": 1e308}), + json!({"max": "50"}), + json!({"seed": -1}), + json!({"seed": 1.5}), + json!({"seed": "x"}), + json!({"ids": [999_999]}), + json!({"ids": "x"}), + ], + ); + let e = s.execute("addon.arrange.randomizeOpacity", &json!({"min": 80, "max": 20})).unwrap_err().to_string(); + assert!(e.contains("`min` is greater than `max`"), "{e}"); +} + +fn layer_order(s: &Session) -> Vec { + s.doc().unwrap().doc.layers[0].children().unwrap().iter().map(|n| n.id).collect() +} + +/// Three squares: `a` 30 wide at x 0, `b` 10 wide at x 100, `c` 20 wide at x 200, all selected. +fn squares(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let a = rect(s, 0.0, 0.0, 30.0, 30.0); + let b = rect(s, 100.0, 0.0, 10.0, 10.0); + let c = rect(s, 200.0, 0.0, 20.0, 20.0); + select(s, &[a, b, c]); + (a, b, c) +} + +#[test] +fn sort_by_area_lines_objects_up_along_x() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + let n = steps(&s); + let r = s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x"})).unwrap(); + assert_eq!(r["count"], json!(3)); + assert_eq!(steps(&s), n + 1, "one undo step"); + // Smallest first: b, c, a take the left edges 0, 100, 200; y stays. + assert!(near(bounds(&s, b), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (100.0, 0.0, 20.0, 20.0)), "{:?}", bounds(&s, c)); + assert!(near(bounds(&s, a), (200.0, 0.0, 30.0, 30.0)), "{:?}", bounds(&s, a)); + // Large to small: a, c, b. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!((bounds(&s, a).x0, bounds(&s, c).x0, bounds(&s, b).x0), (0.0, 100.0, 200.0)); + // Already in order: no step. + let n = steps(&s); + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!(steps(&s), n); +} + +#[test] +fn sort_distributes_values_evenly() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 10.0, 50.0, 10.0, 50.0); + let c = rect(&mut s, 200.0, 0.0, 10.0, 20.0); + select(&mut s, &[a, b, c]); + // Left edges 0, 10, 200 step evenly: 0, 100, 200. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "x", "distribute": true})).unwrap(); + assert_eq!((bounds(&s, a).x0, bounds(&s, b).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0)); + // Heights that step evenly with x (10 to 50), each about its centre. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "height", "distribute": true})).unwrap(); + assert!(near(bounds(&s, a), (0.0, 0.0, 10.0, 10.0)), "{:?}", bounds(&s, a)); + assert!(near(bounds(&s, b), (100.0, 60.0, 10.0, 30.0)), "{:?}", bounds(&s, b)); + assert!(near(bounds(&s, c), (200.0, -15.0, 10.0, 50.0)), "{:?}", bounds(&s, c)); +} + +#[test] +fn sort_restacks_within_each_layer() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Largest at the back. + s.execute("addon.arrange.sort", &json!({"by": "area", "apply": "stacking", "order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [a, c, b]); + s.execute("addon.arrange.sort", &json!({})).unwrap(); + assert_eq!(layer_order(&s), [b, c, a], "by area, small to large, into the stacking order by default"); + // An object in another layer keeps its layer; the others trade places among themselves. + s.execute("layer.new", &json!({})).unwrap(); + let big = rect(&mut s, 0.0, 100.0, 50.0, 50.0); + select(&mut s, &[a, b, c, big]); + s.execute("addon.arrange.sort", &json!({"order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [a, c, b]); + let doc = &s.doc().unwrap().doc; + assert_ne!(doc.layer_of(big), doc.layer_of(a), "left in its own layer"); +} + +#[test] +fn sort_by_stacking_and_opacity() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Back to front a, b, c; reversed: c, b, a take the left edges 0, 100, 200. + s.execute("addon.arrange.sort", &json!({"by": "stacking", "apply": "x", "order": "descending"})).unwrap(); + assert_eq!((bounds(&s, c).x0, bounds(&s, b).x0, bounds(&s, a).x0), (0.0, 100.0, 200.0)); + for (id, o) in [(a, 20), (b, 80), (c, 50)] { + s.execute("object.setProps", &json!({"ids": [id.0], "opacity": o})).unwrap(); + } + select(&mut s, &[a, b, c]); + let opacity = |s: &Session, id| s.doc().unwrap().doc.node(id).unwrap().opacity; + // By x, large to small: a (x 200) gets the least opacity, then b, then c. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "opacity", "order": "descending"})).unwrap(); + assert_eq!((opacity(&s, a), opacity(&s, b), opacity(&s, c)), (0.2, 0.5, 0.8)); + // By opacity into the stacking order, large to small: the most opaque at the back. + s.execute("addon.arrange.sort", &json!({"by": "opacity", "order": "descending"})).unwrap(); + assert_eq!(layer_order(&s), [c, b, a]); +} + +#[test] +fn sort_by_area_into_areas_scales_in_proportion() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + // Areas 900, 100, 400 by x: a (x 0) 100, b 500, c 900. + s.execute("addon.arrange.sort", &json!({"by": "x", "apply": "area", "distribute": true})).unwrap(); + let side = |s: &Session, id| bounds(s, id).width(); + assert!((side(&s, a) - 10.0).abs() < 1e-6 && (side(&s, b) - 500f64.sqrt()).abs() < 1e-6 && (side(&s, c) - 30.0).abs() < 1e-6); + assert!((bounds(&s, b).height() - 500f64.sqrt()).abs() < 1e-6, "in proportion"); +} + +#[test] +fn sort_in_random_order_repeats_with_a_seed() { + let mut s = session(); + let ids: Vec = (0..12).map(|i| rect(&mut s, i as f64 * 10.0, 0.0, 5.0, 5.0)).collect(); + select(&mut s, &ids); + s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap(); + let first = layer_order(&s); + assert_ne!(first, ids, "shuffled"); + let mut sorted = first.clone(); + sorted.sort_by_key(|i| i.0); + assert_eq!(sorted, ids, "the same objects"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(layer_order(&s), ids); + s.execute("addon.arrange.sort", &json!({"order": "random", "seed": 5})).unwrap(); + assert_eq!(layer_order(&s), first, "the same seed, the same order"); +} + +#[test] +fn sort_takes_ids_and_refuses_bad_params() { + let mut s = session(); + let (a, b, c) = squares(&mut s); + select(&mut s, &[a]); + assert!(!enabled(&s, "addon.arrange.sort")); + s.execute("addon.arrange.sort", &json!({"ids": [b.0, a.0], "apply": "x"})).unwrap(); + assert_eq!((bounds(&s, b).x0, bounds(&s, a).x0, bounds(&s, c).x0), (0.0, 100.0, 200.0), "c is left alone"); + select(&mut s, &[a, b, c]); + refuses( + &mut s, + "addon.arrange.sort", + &[ + json!({"by": "color"}), + json!({"apply": 3}), + json!({"order": "up"}), + json!({"distribute": "yes"}), + json!({"seed": -1}), + json!({"seed": 0.5}), + json!({"scaleStrokes": 1}), + json!({"ids": "x"}), + json!({"ids": [999_999]}), + ], + ); +} diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 4f335dc0c..7f78aa246 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -38,7 +38,7 @@ use symbols::selected_instances; pub(crate) use symbols::{instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol}; use symbols::{ symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_edit, symbol_list, symbol_new, symbol_options, symbol_place, - symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_update, + symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, }; const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; @@ -228,7 +228,7 @@ pub fn specs() -> Vec { "Delete Symbol", [], None, - "{name, expandInstances?: true} delete a symbol; its instances are expanded (or deleted with false)", + "{name | names: [name…], expandInstances?: true} delete a symbol (or several, in one undo step); its instances are expanded (or deleted with false)", has_doc, symbol_delete ), @@ -269,6 +269,15 @@ pub fn specs() -> Vec { has_doc, symbol_set_current ), + cmd!( + query "symbol.unused", + "Select All Unused", + [], + None, + "{} → {names: [the symbols nothing places, in panel order]} (the Symbols panel selects them): instances in the art (opacity masks included), graph designs, pattern tiles (with the tiles that pattern editing's Cancel puts back) and brush art count, and so do instances in the art of a symbol that is used", + has_doc, + symbol_unused + ), cmd!(query "symbol.selectInstances", "Select All Instances", [], None, "{name} select every instance of a symbol → {count}", has_doc, symbol_select_instances), cmd!( "symbol.spray", diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index c7fe43354..556926309 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -107,6 +107,37 @@ fn instances_of(d: &Document, name: Option<&str>) -> Vec { v } +/// The symbols an instance in `n` (its opacity masks included) places, pushed onto `out`. +fn art_symbols(n: &Node, out: &mut Vec) { + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { + out.push(symbol.clone()); + } + }); +} + +/// Select All Unused (#4918): the symbols nothing places. Instances in the art (opacity masks +/// included), graph designs, pattern tiles and the tiles that pattern editing's Cancel puts back, +/// and the art of the document's brushes count, and so do the instances in the art of every symbol +/// a counted instance places. A symbol placed only inside an unused symbol is unused too. +pub(super) fn symbol_unused(s: &mut Session, _: &Value) -> Result { + let d = &s.doc()?.doc; + let mut todo = vec![]; + let lib = brush::library(d); + let patterns = d.patterns.iter().chain(d.pattern_edit.iter().filter_map(|pe| pe.original.as_ref())); + let trees = d.layers.iter().map(|l| &**l).chain(d.graph_designs.iter().map(|g| &*g.art)); + trees.chain(patterns.flat_map(|p| p.art.iter().map(|n| &**n))).chain(lib.iter().flat_map(|b| b.art())).for_each(|n| art_symbols(n, &mut todo)); + let mut used = std::collections::BTreeSet::new(); + while let Some(name) = todo.pop() { + if !used.contains(&name) { + d.symbols.iter().filter(|sym| sym.name == name).for_each(|sym| art_symbols(&sym.art, &mut todo)); + used.insert(name); + } + } + let names: Vec<&str> = d.symbols.iter().map(|sym| sym.name.as_str()).filter(|n| !used.contains(*n)).collect(); + Ok(json!({ "names": names })) +} + fn symbol_names(d: &Document) -> Vec { d.symbols.iter().map(|s| s.name.clone()).collect() } @@ -417,27 +448,74 @@ pub(super) fn symbol_update(s: &mut Session, p: &Value) -> Result { Ok(json!({ "name": name, "id": id.0 })) } +/// How many levels of nested instances Delete Symbol expands before it gives up. +const MAX_DELETE_ROUNDS: usize = 256; + pub(super) fn symbol_delete(s: &mut Session, p: &Value) -> Result { const C: &str = "symbol.delete"; - let name = name_param(p, C)?.to_string(); - if !s.doc()?.doc.symbols.iter().any(|x| x.name == name) { + // `names` deletes several symbols in one undo step (the Symbols panel's Select All Unused, #4918). + let names: Vec = match p.get("names") { + Some(v) => { + let a = v.as_array().ok_or_else(|| bad(C, "names must be an array of symbol names"))?; + let mut names = Vec::with_capacity(a.len()); + for n in a { + let n = n.as_str().ok_or_else(|| bad(C, "names must be an array of symbol names"))?; + if !names.iter().any(|x: &String| x == n) { + names.push(n.to_string()); + } + } + if names.is_empty() { + return Err(bad(C, "names is empty")); + } + names + } + None => vec![name_param(p, C)?.to_string()], + }; + let d = &s.doc()?.doc; + if let Some(name) = names.iter().find(|n| !d.symbols.iter().any(|x| &x.name == *n)) { return Err(bad(C, format!("no symbol named `{name}`"))); } let expand = switch_or(p, "expandInstances", true, C)?; - s.edit("Delete Symbol", |d, _| { - let inst = instances_of(d, Some(&name)); - if expand { - break_links(d, &inst, true)?; - } else { - remove_each(d, &inst)?; + let label = if names.len() == 1 { "Delete Symbol" } else { "Delete Symbols" }; + s.edit(label, |d, _| { + // Every instance goes before any definition, whatever the order of `names`: expanding an + // instance of one symbol can bring out instances of another being deleted (nested in its + // art), which expand in turn while their definitions are still there. Each round expands + // one level of nesting, so the rounds are capped. + let of_names = |d: &Document| { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::SymbolInstance { symbol, .. } = &n.kind + && names.contains(symbol) + { + v.push(n.id); + } + }); + v + }; + for _ in 0..=MAX_DELETE_ROUNDS { + let inst = of_names(d); + if inst.is_empty() { + break; + } + if expand { + break_links(d, &inst, true)?; + } else { + remove_each(d, &inst)?; + } } - d.symbols.retain(|x| x.name != name); - set_natural_size(d, &name, None); - d.set_symbol_registration(&name, SymbolRegistration::CENTER); - d.clear_symbol_export_type(&name); - d.set_symbol_nine_slice(&name, None); - if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { - d.unknown.remove(CURRENT_SYMBOL); + if !of_names(d).is_empty() { + return Err(EngineError::Other("the symbols are nested too deeply to expand their instances".into())); + } + for name in &names { + d.symbols.retain(|x| &x.name != name); + set_natural_size(d, name, None); + d.set_symbol_registration(name, SymbolRegistration::CENTER); + d.clear_symbol_export_type(name); + d.set_symbol_nine_slice(name, None); + if d.unknown.get(CURRENT_SYMBOL).and_then(Value::as_str) == Some(name.as_str()) { + d.unknown.remove(CURRENT_SYMBOL); + } } Ok(()) })?; diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index b873f3638..4079b4230 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -124,8 +124,9 @@ pub(crate) use export_names::{default_name, merge, unique_file_names, with_black use export_names::{distinct_names, write_encoded}; use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, writable}; pub use formats::{ - DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, file_stem, - format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, writable_format, + DOC_EXTS, DOCX_EXTS, FORMATS, Format, FormatOption, OPEN_EXTS, PLACE_EXTS, RTF_EXTS, TEXT_EXTS, UNASSOCIATED_EXTS, export_extensions, extension, + file_stem, format, format_for_name, has_own_title, library_open_filters, open_filters, place_filters, plugin_formats, titled_for_export, + writable_format, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/fileio/formats.rs b/crates/engine/src/cmd/fileio/formats.rs index a1687e214..8df178845 100644 --- a/crates/engine/src/cmd/fileio/formats.rs +++ b/crates/engine/src/cmd/fileio/formats.rs @@ -566,8 +566,14 @@ pub const RTF_EXTS: &[&str] = &["rtf"]; /// Word documents: File → Place sets them as area type with their formatting. pub const DOCX_EXTS: &[&str] = &["docx"]; -/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`] and -/// [`DOCX_EXTS`]. +/// Word 97-2003 documents: File → Place sets them as area type with their formatting. +pub const DOC_EXTS: &[&str] = &["doc"]; + +/// The Place dialog's Word filter: Word documents and Word 97-2003 documents. +const WORD_EXTS: &[&str] = &["docx", "doc"]; + +/// Every extension File → Place reads: [`OPEN_EXTS`], [`TEXT_EXTS`], [`RTF_EXTS`], +/// [`DOCX_EXTS`] and [`DOC_EXTS`]. pub const PLACE_EXTS: &[&str] = &[ "vectorcraft", "drawcraft", @@ -610,6 +616,7 @@ pub const PLACE_EXTS: &[&str] = &[ "txt", "rtf", "docx", + "doc", ]; /// One dialog filter per readable format. @@ -645,13 +652,13 @@ pub fn library_open_filters( } /// File → Place dialog filters: "All placeable files", then one per placeable format, then text, -/// RTF and Word. +/// RTF and Word (`.docx` and `.doc`). pub fn place_filters() -> impl Iterator { 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))) - .chain(std::iter::once(("Word", DOCX_EXTS))) + .chain(std::iter::once(("Word", WORD_EXTS))) } /// A format by id or extension (any case, leading dot allowed; `jpeg` finds `jpg`): a built-in diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 2ab05f69e..d76de1adb 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -20,6 +20,8 @@ //! - An RTF file becomes area type with its character and paragraph formatting ([`rtf`]). //! - A Word document (`.docx`) becomes area type with the character and paragraph formatting its //! styles and direct formatting give ([`docx`]). +//! - A Word 97-2003 document (`.doc`) becomes area type the same way, its main text read from its +//! compound file, with a warning for each kind of content left out ([`doc`]). //! - 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. @@ -30,6 +32,7 @@ //! `place` tool) with several files, which emits one `file.place` per click or drag. pub(crate) mod adopt; +mod doc; pub(crate) mod document; pub(crate) mod docx; mod rtf; @@ -169,6 +172,15 @@ fn load(p: &Value, cmd: &str, board: Option, link_pdf: bool) -> Result) +//! from the compound file \[MS-CFB\] describes +//! (). +//! +//! - The `WordDocument` stream starts with the file information block (FIB, §2.5.1), which locates +//! the structures read from the `0Table` or `1Table` stream (`fWhichTblStm`). Documents saved by +//! Word 97 and later (`nFib` 0x00C1 and above) are read; older ones, encrypted or obfuscated ones +//! and ones last saved with fast save (`fComplex`) are refused. +//! - Text: the main document's `ccpText` characters, from the pieces the Clx's PlcPcd lists +//! (§2.9.38, §2.8.35) in their order, each in 16-bit Unicode or in compressed 8-bit text with the +//! mapping §2.9.73 gives; 16-bit text with half of a surrogate pair is refused. A paragraph mark +//! (U+000D) ends a paragraph, and so does a page or section break (U+000C) at the end of a +//! paragraph's properties (PapxFkp); a line break +//! (U+000B) starts a new paragraph in the same formatting. A table's cells (cell marks, U+0007) +//! are separated by tabs and each row (a table terminating mark) is a paragraph. Tabs are tabs, +//! and U+001E and U+001F are the nonbreaking and optional hyphens. Of a field (U+0013, U+0014 and +//! U+0015), its result is read. +//! - Character formatting: the font (`sprmCRgFtc0`, named by the font table SttbfFfn), the size, +//! bold, italic, underline (every style but none), strikethrough and double strikethrough, all +//! caps, small caps, superscript and subscript, raised and lowered text (`sprmCHpsPos`) and the +//! color (`sprmCIco`, `sprmCCv`). +//! - Paragraph formatting: alignment (`sprmPJc80`, then `sprmPJc`: start and end follow the +//! direction, and the kashida alignments are justified with Short, Medium or Long kashidas), the +//! left and right indents (the physical ones Word 97 writes, unless the logical ones later +//! versions write are given), first-line indent, space before and after, and direction +//! (`sprmPFBiDi`). +//! - Formatting is resolved as "Determining Formatting Properties" describes: the style sheet's +//! default font and 10 pt, then the paragraph style (STSH, §2.9.271) and the styles it is based +//! on, then the run's character style (`sprmCIstd`) and the styles it is based on, then the +//! direct formatting of the run (Chpx) and of the paragraph (Papx). A toggle operand of 0x80 or +//! 0x81 takes or reverses the value the styles give. +//! +//! Hidden text, deleted revisions, field instructions, pictures and embedded objects, note and +//! comment references and the other stories (footnotes, endnotes, headers, footers, comments and +//! text boxes) are left out. The reply's warnings name each kind of content that was left out or +//! changed, once: pictures and objects, fields, fields left out (no result, or nested too deep), the +//! other stories, tracked changes and tables. A stream larger than [`MAX_STREAM`] bytes, a main +//! text longer than [`text::MAX_TEXT`] characters, more than [`MAX_PIECES`] pieces or +//! [`MAX_PAGES`] property pages, and placed type of more than [`MAX_PARAGRAPHS`] paragraphs or +//! [`MAX_RUNS`] runs are refused, and so is a structure that lies outside its stream. + +use std::io::{Cursor, Read}; + +use vectorcraft_doc::text::Kashida; +use vectorcraft_doc::{CharPosition, CharStyle, Justify, ParaDirection, ParaStyle, ScriptMetrics, TextRun}; + +use super::text::{self, Story}; +use super::{EngineError, Paint, Result, bad, fileio}; + +/// Word 97-2003 documents as `file.place` reports them (not a format `document.open` reads). +pub(super) const FORMAT: fileio::Format = fileio::Format { + id: "doc", + label: "Word 97-2003", + extensions: fileio::DOC_EXTS, + mime: "application/msword", + read: true, + write: false, + raster: false, + options: &[], +}; + +/// The first bytes of a compound file (\[MS-CFB\] §2.2). +const SIGNATURE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; +/// The most bytes a stream read from the compound file may hold. +const MAX_STREAM: u64 = 64 << 20; +/// The most pieces the main text may be made of. +const MAX_PIECES: usize = 100_000; +/// The most 512-byte pages of character or of paragraph properties read. +const MAX_PAGES: usize = 16_384; +/// The most styles read from the style sheet (`Stshif.cstd` is below 0x0FFE). +const MAX_STYLES: usize = 0x0FFE; +/// The most styles one style's chain of `istdBase` takes in. +const MAX_BASED_ON: usize = 32; +/// The most fonts read from the font table (SttbfFfn's `cData` is at most 0x7FF0). +const MAX_FONTS: usize = 0x7FF0; +/// The most bytes of a font name kept. +const MAX_NAME: usize = 256; +/// The most paragraphs placed type takes from one Word document. +const MAX_PARAGRAPHS: usize = 100_000; +/// The most runs of one character formatting placed type takes from one Word document. +const MAX_RUNS: usize = 100_000; +/// The deepest nesting of fields read; deeper ones are left out with their results. +const MAX_FIELDS: usize = 64; +/// `istdBase` of a style that is based on none. +const NO_STYLE: u16 = 0x0FFF; + +/// The FibRgFcLcb97 pairs read (§2.5.6), by their index. +const STSHF: usize = 1; +const PLCF_BTE_CHPX: usize = 12; +const PLCF_BTE_PAPX: usize = 13; +const STTBF_FFN: usize = 15; +const CLX: usize = 33; +/// The number of pairs FibRgFcLcb97 holds. +const FC_LCB_97: u16 = 0x005D; + +/// Is the file `name` with `bytes` a Word 97-2003 document: a compound file named `.doc`, or one +/// that holds a `WordDocument` stream? +pub(super) fn is_doc(name: &str, bytes: &[u8]) -> bool { + bytes.starts_with(&SIGNATURE) + && (fileio::DOC_EXTS.contains(&fileio::extension(name).as_str()) + || cfb::CompoundFile::open(Cursor::new(bytes)).is_ok_and(|f| f.is_stream("/WordDocument"))) +} + +/// The main text of a Word 97-2003 document's `bytes` (a compound file) with its character and +/// paragraph formatting, and a warning for each kind of content left out or changed. +pub(super) fn import(bytes: &[u8], cmd: &str) -> Result<(Story, Vec)> { + let mut file = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|_| damaged(cmd, "its compound file structure is damaged"))?; + let wd = stream(&mut file, "WordDocument", cmd)?.ok_or_else(|| bad(cmd, "not a Word document: its compound file has no WordDocument stream"))?; + let fib = Fib::read(&wd, cmd)?; + let table_name = if fib.table_one { "1Table" } else { "0Table" }; + let table = stream(&mut file, table_name, cmd)?.ok_or_else(|| damaged(cmd, format!("it has no {table_name} stream")))?; + let clx = fib.part(&table, CLX, cmd)?.ok_or_else(|| damaged(cmd, "it has no piece table"))?; + let text = main_text(&wd, &pieces(clx, cmd)?, fib.ccp_text, cmd)?; + let chpx = match fib.part(&table, PLCF_BTE_CHPX, cmd)? { + Some(plc) => runs(&wd, plc, Fkp::Chpx, cmd)?, + None => vec![], + }; + let papx = match fib.part(&table, PLCF_BTE_PAPX, cmd)? { + Some(plc) => runs(&wd, plc, Fkp::Papx, cmd)?, + None => vec![], + }; + let sheet = match fib.part(&table, STSHF, cmd)? { + Some(stsh) => Sheet::read(stsh, cmd)?, + None => Sheet::default(), + }; + let fonts = match fib.part(&table, STTBF_FFN, cmd)? { + Some(sttb) => fonts(sttb, cmd)?, + None => vec![], + }; + let mut r = Reader::new(&sheet, &fonts); + r.read(&text, &chpx, &papx); + let warnings = r.warnings(&fib); + Ok((r.finish(cmd)?, warnings)) +} + +/// The error for a Word document whose structures can't be read. +fn damaged(cmd: &str, why: impl std::fmt::Display) -> EngineError { + bad(cmd, format!("can't read this Word document: {why}")) +} + +/// The stream `name` of the compound file; None when it has no such stream. +fn stream(file: &mut cfb::CompoundFile>, name: &str, cmd: &str) -> Result>> { + if !file.is_stream(name) { + return Ok(None); + } + let s = file.open_stream(name).map_err(|_| damaged(cmd, format!("its {name} stream is damaged")))?; + if s.len() > MAX_STREAM { + return Err(bad(cmd, format!("the Word document is too large to place (its {name} stream holds more than {} MB)", MAX_STREAM >> 20))); + } + let mut out = vec![]; + s.take(MAX_STREAM).read_to_end(&mut out).map_err(|_| damaged(cmd, format!("its {name} stream is damaged")))?; + Ok(Some(out)) +} + +/// The little-endian 16-bit integer at `at`. +fn u16_at(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(2)?).and_then(|s| s.try_into().ok()).map(u16::from_le_bytes) +} + +/// The little-endian 32-bit integer at `at`. +fn u32_at(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).and_then(|s| s.try_into().ok()).map(u32::from_le_bytes) +} + +/// `len` bytes of `b` from `at`. +fn bytes_at(b: &[u8], at: usize, len: usize) -> Option<&[u8]> { + b.get(at..at.checked_add(len)?) +} + +/// What the reader takes from the file information block. +struct Fib { + /// The table stream is `1Table` (else `0Table`). + table_one: bool, + /// The main document's length in characters. + ccp_text: u32, + /// The lengths of the footnotes, headers and footers, comments, endnotes, text boxes and + /// header text boxes (`ccpFtn`, `ccpHdd`, `ccpAtn`, `ccpEdn`, `ccpTxbx`, `ccpHdrTxbx`). + stories: [u32; 6], + /// FibRgFcLcb's offset and size pairs. + fc_lcb: Vec<(u32, u32)>, +} + +impl Fib { + /// The FIB at the start of the WordDocument stream `wd` (§2.5.1). + fn read(wd: &[u8], cmd: &str) -> Result { + if u16_at(wd, 0) != Some(0xA5EC) { + return Err(bad(cmd, "not a Word document: its WordDocument stream doesn't start with a file information block")); + } + let field = |at: usize| u16_at(wd, at).ok_or_else(|| damaged(cmd, "its file information block is cut short")); + if field(2)? < 0x00C1 { + return Err(bad( + cmd, + "the Word document was saved by Word 6.0 or Word 95, which VectorCraft doesn't read; save it as a Word 97-2003 document", + )); + } + let flags = field(10)?; + // fEncrypted (fObfuscated only says how). + if flags & 0x0100 != 0 { + return Err(bad(cmd, "the Word document is encrypted or protected with a password; save it without one to place it")); + } + // fComplex: the last save was an incremental (fast) save. + if flags & 0x0004 != 0 { + return Err(bad( + cmd, + "the Word document was last saved with fast save, which VectorCraft doesn't read; save it again with fast save turned off", + )); + } + // csw (14 16-bit values) and cslw (22 32-bit values) are fixed. + if field(32)? != 0x000E || field(62)? != 0x0016 { + return Err(damaged(cmd, "its file information block is damaged")); + } + let count = field(152)?; + if count < FC_LCB_97 { + return Err(damaged(cmd, "its file information block is damaged")); + } + let pair = |i: usize| Some((u32_at(wd, 154 + 8 * i)?, u32_at(wd, 158 + 8 * i)?)); + let fc_lcb = + (0..usize::from(count)).map(pair).collect::>>().ok_or_else(|| damaged(cmd, "its file information block is cut short"))?; + // FibRgLw97 starts at 64: ccpText is its fourth value. + let lw = |i: usize| u32_at(wd, 64 + 4 * i).unwrap_or_default(); + Ok(Self { table_one: flags & 0x0200 != 0, ccp_text: lw(3), stories: [lw(4), lw(5), lw(7), lw(8), lw(9), lw(10)], fc_lcb }) + } + + /// The structure FibRgFcLcb's pair `index` locates in the table stream `table`; None when it + /// is empty. + fn part<'a>(&self, table: &'a [u8], index: usize, cmd: &str) -> Result> { + let Some(&(fc, lcb)) = self.fc_lcb.get(index).filter(|(_, lcb)| *lcb > 0) else { return Ok(None) }; + bytes_at(table, fc as usize, lcb as usize).map(Some).ok_or_else(|| damaged(cmd, "a table it lists lies outside its table stream")) + } +} + +/// A piece of the text (Pcd): characters `cp..end`, stored from file position `fc`. +struct Piece { + cp: u32, + end: u32, + fc: u32, + /// Compressed 8-bit text (else 16-bit Unicode). + compressed: bool, +} + +/// The pieces the Clx `clx` lists (§2.9.38), in character order. +fn pieces(clx: &[u8], cmd: &str) -> Result> { + let broken = || damaged(cmd, "its piece table is damaged"); + let mut at = 0usize; + // Prc records (property modifiers for pieces) come first. + while clx.get(at) == Some(&0x01) { + let size = u16_at(clx, at + 1).map(|v| v as i16).filter(|v| *v >= 0).ok_or_else(broken)?; + at += 3 + size as usize; + } + if clx.get(at) != Some(&0x02) { + return Err(broken()); + } + let lcb = u32_at(clx, at + 1).ok_or_else(broken)? as usize; + let plc = bytes_at(clx, at + 5, lcb).ok_or_else(broken)?; + if lcb < 4 || !(lcb - 4).is_multiple_of(12) { + return Err(broken()); + } + let n = (lcb - 4) / 12; + if n > MAX_PIECES { + return Err(bad(cmd, format!("the Word document's text is made of more than {MAX_PIECES} pieces, too many to place"))); + } + let mut out = Vec::with_capacity(n); + for i in 0..n { + let (cp, end) = (u32_at(plc, 4 * i).ok_or_else(broken)?, u32_at(plc, 4 * i + 4).ok_or_else(broken)?); + // A Pcd: 2 bytes of flags, then FcCompressed (fc in bits 0 to 29, fCompressed in bit 30). + let raw = u32_at(plc, 4 * (n + 1) + 8 * i + 2).ok_or_else(broken)?; + if end <= cp || out.last().is_some_and(|p: &Piece| p.end != cp) || (i == 0 && cp != 0) { + return Err(broken()); + } + let compressed = raw & 0x4000_0000 != 0; + let fc = raw & 0x3FFF_FFFF; + out.push(Piece { cp, end, fc: if compressed { fc / 2 } else { fc }, compressed }); + } + Ok(out) +} + +/// A character of the main text: the character, the file position of its bytes, and their number. +type Char = (char, u32, u8); + +/// The first `ccp` characters of the pieces, read from the WordDocument stream `wd`. +fn main_text(wd: &[u8], pieces: &[Piece], ccp: u32, cmd: &str) -> Result> { + if ccp as usize > text::MAX_TEXT { + return Err(bad(cmd, format!("the Word document has too much text to place as type (at most {} MB)", text::MAX_TEXT >> 20))); + } + if pieces.last().is_none_or(|p| p.end < ccp) { + return Err(damaged(cmd, "its piece table doesn't cover its text")); + } + let outside = || damaged(cmd, "a piece of its text lies outside its WordDocument stream"); + // Each character position's UTF-16 code unit, file position and byte count, in character + // order; a surrogate pair may be split between two pieces. + let mut units: Vec<(u16, u32, u8)> = Vec::with_capacity(ccp as usize); + for p in pieces.iter().take_while(|p| p.cp < ccp) { + let n = (p.end.min(ccp) - p.cp) as usize; + if p.compressed { + let b = bytes_at(wd, p.fc as usize, n).ok_or_else(outside)?; + // Every character compressed text maps to is in the Basic Multilingual Plane. + units.extend(b.iter().zip(p.fc..).map(|(&c, fc)| (u16::try_from(u32::from(compressed(c))).unwrap_or(0xFFFD), fc, 1))); + continue; + } + let b = bytes_at(wd, p.fc as usize, n.checked_mul(2).ok_or_else(outside)?).ok_or_else(outside)?; + units.extend(b.as_chunks::<2>().0.iter().zip((p.fc..).step_by(2)).map(|(&u, fc)| (u16::from_le_bytes(u), fc, 2))); + } + let mut out = Vec::with_capacity(units.len()); + let mut i = 0; + while let Some(&(u, fc, width)) = units.get(i) { + let low = units.get(i + 1).map(|&(low, _, w)| (low, w)).filter(|(low, _)| (0xD800..0xDC00).contains(&u) && (0xDC00..0xE000).contains(low)); + let c = match low { + Some((low, _)) => char::decode_utf16([u, low]).next().and_then(|c| c.ok()), + None => char::from_u32(u32::from(u)), + }; + let c = c.ok_or_else(|| damaged(cmd, "its text holds half of a UTF-16 surrogate pair"))?; + out.push((c, fc, low.map_or(width, |(_, w)| width + w))); + i += if low.is_some() { 2 } else { 1 }; + } + Ok(out) +} + +/// Byte `b` of compressed text (§2.9.73): the byte as a Unicode character, except the ones that +/// stand for these. +fn compressed(b: u8) -> char { + const MAPPED: [(u8, char); 24] = [ + (0x82, '\u{201A}'), + (0x83, '\u{0192}'), + (0x84, '\u{201E}'), + (0x85, '\u{2026}'), + (0x86, '\u{2020}'), + (0x87, '\u{2021}'), + (0x88, '\u{02C6}'), + (0x89, '\u{2030}'), + (0x8A, '\u{0160}'), + (0x8B, '\u{2039}'), + (0x8C, '\u{0152}'), + (0x91, '\u{2018}'), + (0x92, '\u{2019}'), + (0x93, '\u{201C}'), + (0x94, '\u{201D}'), + (0x95, '\u{2022}'), + (0x96, '\u{2013}'), + (0x97, '\u{2014}'), + (0x98, '\u{02DC}'), + (0x99, '\u{2122}'), + (0x9A, '\u{0161}'), + (0x9B, '\u{203A}'), + (0x9C, '\u{0153}'), + (0x9F, '\u{0178}'), + ]; + MAPPED.iter().find(|(k, _)| *k == b).map_or(char::from(b), |(_, c)| *c) +} + +/// Which property pages a bin table points to. +#[derive(Clone, Copy, PartialEq)] +enum Fkp { + /// ChpxFkp (§2.9.33): character properties. + Chpx, + /// PapxFkp (§2.9.174): paragraph properties. + Papx, +} + +/// The file positions `start..end` and the properties a property page gives them. +struct Run { + start: u32, + end: u32, + /// The paragraph style (Papx only). + istd: u16, + grpprl: Vec, +} + +/// The runs of the property pages the bin table `plc` (PlcBteChpx or PlcBtePapx) points to, in +/// the WordDocument stream `wd`, ordered by file position. +fn runs(wd: &[u8], plc: &[u8], kind: Fkp, cmd: &str) -> Result> { + let broken = || damaged(cmd, "its character or paragraph properties are damaged"); + if plc.len() < 4 || !(plc.len() - 4).is_multiple_of(8) { + return Err(broken()); + } + let n = (plc.len() - 4) / 8; + if n > MAX_PAGES { + return Err(bad(cmd, format!("the Word document has more than {MAX_PAGES} pages of properties, too many to place"))); + } + let mut out = vec![]; + for i in 0..n { + // PnFkpChpx and PnFkpPapx: the page number in bits 0 to 21. + let pn = u32_at(plc, 4 * (n + 1) + 4 * i).ok_or_else(broken)? & 0x003F_FFFF; + let page = bytes_at(wd, pn as usize * 512, 512).ok_or_else(broken)?; + page_runs(page, kind, &mut out).ok_or_else(broken)?; + } + out.sort_by_key(|r| r.start); + Ok(out) +} + +/// The runs of one 512-byte property page; None when it is damaged. +fn page_runs(page: &[u8], kind: Fkp, out: &mut Vec) -> Option<()> { + let count = usize::from(*page.get(511)?); + let most = if kind == Fkp::Chpx { 0x65 } else { 0x1D }; + if count == 0 || count > most { + return None; + } + for i in 0..count { + let (start, end) = (u32_at(page, 4 * i)?, u32_at(page, 4 * i + 4)?); + // ChpxFkp's rgb: one byte each; PapxFkp's rgbx: 13-byte BxPap, its first byte the offset. + let entry = 4 * (count + 1) + if kind == Fkp::Chpx { i } else { 13 * i }; + let offset = usize::from(*page.get(entry)?) * 2; + let (istd, grpprl) = match (offset, kind) { + (0, _) => (0, vec![]), + (_, Fkp::Chpx) => { + let cb = usize::from(*page.get(offset)?); + (0, bytes_at(page, offset + 1, cb).filter(|_| offset + 1 + cb <= 511)?.to_vec()) + } + (_, Fkp::Papx) => { + // PapxInFkp: 2 × cb − 1 bytes, or (cb = 0) 2 × cb′ bytes after cb′. + let (at, len) = match usize::from(*page.get(offset)?) { + 0 => (offset + 2, usize::from(*page.get(offset + 1)?) * 2), + cb => (offset + 1, 2 * cb - 1), + }; + let grp = bytes_at(page, at, len).filter(|_| at + len <= 511 && len >= 2)?; + (u16_at(grp, 0)?, grp.get(2..)?.to_vec()) + } + }; + if end <= start { + return None; + } + out.push(Run { start, end, istd, grpprl }); + } + Some(()) +} + +/// The run of `runs` (ordered by file position) that holds file position `fc`. +fn run_at(runs: &[Run], fc: u32) -> Option { + let i = runs.partition_point(|r| r.start <= fc).checked_sub(1)?; + runs.get(i).filter(|r| fc < r.end).map(|_| i) +} + +/// The size of the operand of `sprm` (§2.2.5.1) whose bytes start `rest`. +fn operand_len(sprm: u16, rest: &[u8]) -> Option { + Some(match sprm >> 13 { + 0 | 1 => 1, + 2 | 4 | 5 => 2, + 3 => 4, + 7 => 3, + // sprmTDefTable: a 2-byte size of the rest, plus 1. + _ if sprm == 0xD608 => usize::from(u16_at(rest, 0)?) + 1, + // sprmPChgTabs with cb 255: the deleted tabs with their close tolerances, then the added + // tabs with their descriptors. + _ if sprm == 0xC615 && rest.first() == Some(&255) => { + let deleted = usize::from(*rest.get(1)?); + let added = usize::from(*rest.get(2 + 4 * deleted)?); + 3 + 4 * deleted + 3 * added + } + _ => usize::from(*rest.first()?) + 1, + }) +} + +/// Calls `f` with each Prl of `grpprl`: its sprm and its operand. +fn each_prl(grpprl: &[u8], mut f: impl FnMut(u16, &[u8])) { + let mut at = 0; + while let Some(sprm) = u16_at(grpprl, at) { + let rest = grpprl.get(at + 2..).unwrap_or_default(); + let Some((n, op)) = operand_len(sprm, rest).and_then(|n| Some((n, rest.get(..n)?))) else { return }; + // A variable operand's bytes follow its size. + let op = if sprm >> 13 == 6 && !matches!(sprm, 0xD608 | 0xC615) { op.get(1..).unwrap_or_default() } else { op }; + f(sprm, op); + at += 2 + n; + } +} + +/// A style of the style sheet. +struct Style { + /// 1: paragraph style, 2: character style. + stk: u8, + base: u16, + papx: Vec, + chpx: Vec, +} + +/// The style sheet: its styles by istd and its default font. +#[derive(Default)] +struct Sheet { + styles: Vec>, + /// `Stshif.ftcAsci`. + font: Option, +} + +impl Sheet { + /// The style sheet STSH (§2.9.271). + fn read(stsh: &[u8], cmd: &str) -> Result { + let broken = || damaged(cmd, "its style sheet is damaged"); + let size = usize::from(u16_at(stsh, 0).ok_or_else(broken)?); + let stshi = bytes_at(stsh, 2, size).ok_or_else(broken)?; + let count = usize::from(u16_at(stshi, 0).ok_or_else(broken)?).min(MAX_STYLES); + // cbSTDBaseInFile: StdfBase, and StdfPost2000 when it is 18. + let base_size = usize::from(u16_at(stshi, 2).ok_or_else(broken)?).max(10); + let font = u16_at(stshi, 12); + let mut at = 2 + size; + let mut styles = Vec::with_capacity(count); + for _ in 0..count { + let Some(len) = u16_at(stsh, at) else { break }; + let std = bytes_at(stsh, at + 2, usize::from(len)).ok_or_else(broken)?; + styles.push(if std.is_empty() { None } else { Some(style(std, base_size).ok_or_else(broken)?) }); + // LPStd records start on even offsets. + at += 2 + usize::from(len) + usize::from(len) % 2; + } + Ok(Self { styles, font }) + } + + /// Style `istd` of kind `stk` and the styles it is based on, the first of the chain first. + fn chain(&self, istd: u16, stk: u8) -> Vec<&Style> { + let mut seen: Vec = vec![]; + let mut chain = vec![]; + let mut next = istd; + while next != NO_STYLE && !seen.contains(&next) && seen.len() < MAX_BASED_ON { + let Some(s) = self.styles.get(usize::from(next)).and_then(Option::as_ref).filter(|s| s.stk == stk) else { break }; + seen.push(next); + chain.push(s); + next = s.base; + } + chain.reverse(); + chain + } +} + +/// A style definition (STD, §2.9.258) whose Stdf takes `base_size` bytes; None when it is damaged. +fn style(std: &[u8], base_size: usize) -> Option