diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 029a43ec0..3dc1ebf6d 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -102,6 +102,49 @@ pub enum TableOperation { LayerDelete { name: String, erase_objects: bool }, /// Make an existing layer current. LayerSetCurrent { name: String }, + /// Create a text style; unset properties take the AutoCAD defaults. + TextStyleCreate { config: TextStyleConfig }, + /// Change only the properties `config` sets on an existing text style. + TextStyleModify { config: TextStyleConfig }, + /// Create a dimension style, optionally copying an existing one first. + /// `properties` is a JSON object of DimStyle fields (see the Python docs); + /// handles and xref fields are managed by the host and refused. + DimStyleCreate { name: String, copy_from: Option, properties: String }, + /// Change only the DimStyle fields the JSON object `properties` names. + DimStyleModify { name: String, properties: String }, + /// Rename a text or dimension style; references to it follow. `Standard` + /// is never renamed and a case-only change is refused. + StyleRename { kind: TableStyleKind, from: String, to: String }, + /// Delete a text or dimension style that is neither current nor in use. + /// `Standard` is never deleted. + StyleDelete { kind: TableStyleKind, name: String }, + /// Make a text or dimension style current. + StyleSetCurrent { kind: TableStyleKind, name: String }, +} + +/// The style tables `TableOperation` can rename, delete or make current. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TableStyleKind { + Text, + Dim, +} + +/// Properties for creating or changing a text style; `None` leaves a property +/// as it was (or the default for a new style). Angles are in radians. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct TextStyleConfig { + pub name: String, + /// Fixed height; `0` lets each text choose its own. + pub height: Option, + pub width_factor: Option, + pub oblique_angle: Option, + pub font_file: Option, + pub big_font_file: Option, + pub true_type_font: Option, + pub backward: Option, + pub upside_down: Option, + pub vertical: Option, + pub annotative: Option, } /// A kernel operation on ACIS-backed solids (API v7, additive). The host owns diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index fd8a65cff..1960aa9e0 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -122,6 +122,15 @@ mod tests { TableOperation::LayerRename { from: "A".into(), to: "B".into() }, TableOperation::LayerDelete { name: "A".into(), erase_objects: true }, TableOperation::LayerSetCurrent { name: "A".into() }, + TableOperation::TextStyleCreate { + config: crate::host::TextStyleConfig { name: "T".into(), height: Some(2.5), oblique_angle: Some(0.2), backward: Some(true), ..Default::default() }, + }, + TableOperation::TextStyleModify { config: crate::host::TextStyleConfig { name: "T".into(), font_file: Some("romans".into()), ..Default::default() } }, + TableOperation::DimStyleCreate { name: "D".into(), copy_from: Some("Standard".into()), properties: "{\"dimscale\":2}".into() }, + TableOperation::DimStyleModify { name: "D".into(), properties: "{}".into() }, + TableOperation::StyleRename { kind: crate::host::TableStyleKind::Text, from: "A".into(), to: "B".into() }, + TableOperation::StyleDelete { kind: crate::host::TableStyleKind::Dim, name: "A".into() }, + TableOperation::StyleSetCurrent { kind: crate::host::TableStyleKind::Dim, name: "A".into() }, ] { let bytes = bincode::serialize(&PluginRequest::TableOperation { operation: operation.clone() }).unwrap(); assert!(matches!(bincode::deserialize::(&bytes).unwrap(), diff --git a/docs/cadcodec-reader-gaps.md b/docs/cadcodec-reader-gaps.md index 9dbe422c4..47e35e62d 100644 --- a/docs/cadcodec-reader-gaps.md +++ b/docs/cadcodec-reader-gaps.md @@ -195,3 +195,17 @@ These are format constraints or OCS-side issues, not reader bugs: - **Spline weights in DXF** were briefly suspected but are correct: the writer emits group 41 only when `flags.rational` is set, so the host must set that flag when weights are present (fixed in OCS). + +### Text and dimension style findings (2026-09-21, not filed) + +Found by `audit_python_text_and_dim_styles_over_real_ipc`; each is pinned by a canary. + +- **`STYLE` generation flags:** the DXF writer hard-codes group 71 to 0, so a text + style's `flags.backward` and `flags.upside_down` are lost on every DXF save (DWG keeps them). +- **`STYLE` oblique angle units:** group 50 is written and read as the raw radian value, while DXF + defines it in degrees; it round-trips inside cadcodec but AutoCAD would read 15 degrees as 0.26. + DWG stores radians correctly. +- **`DIMSTYLE` text style name:** the DXF reader keeps only the text-style handle (group 340) and never + resolves `dimtxsty` from it, so the name reopens as `Standard` (the handle is right; DWG resolves both). +- **`TextStyle::true_type_font`** is never written or read by either codec, so it lives only in memory. + diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index a7f813499..1db480afc 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -200,3 +200,16 @@ nothing changes. Python: `ocs.active_document.layers` (`create`, `modify`, `rena (19 refusals, live/DWG/DXF persistence of colour, lineweight, transparency, flags, linetype, description, rename following entities, `erase_objects` and one-step undo), an IPC round-trip test and a Python unit test. Next: text and dimension styles, linetypes, blocks, layouts; then headless commands. + +### 2026-09-21: text and dimension styles (`doc.text_styles`, `doc.dim_styles`) + +`TableOperation` grew `TextStyleCreate/Modify`, `DimStyleCreate/Modify` (DimStyle fields travel as a JSON +object, so all 88 are settable with host validation; handles, xref fields and the name are refused), +`StyleRename`, `StyleDelete`, `StyleSetCurrent`. The host reuses OCS's own style machinery (in-use scan, +reference-following rename) by widening four `style_ops` methods to `pub(super)`, and additionally makes a +renamed text style's name follow into dimension styles. Style operations require the active tab. +Evidence: `audit_python_text_and_dim_styles_over_real_ipc` (26 refusals, live/DWG/DXF persistence, copy +semantics, rename following references, current/delete guards, five-step undo), IPC round-trip and Python unit +tests. Four cadcodec findings (STYLE flags and oblique units, DIMSTYLE name, `true_type_font`) are pinned by +canaries and listed in `cadcodec-reader-gaps.md`. Next: linetypes, blocks, layouts; then headless commands. + diff --git a/plugins/opencad-python/PLUGIN.md b/plugins/opencad-python/PLUGIN.md index cb8b5b5ea..49a2f4b75 100644 --- a/plugins/opencad-python/PLUGIN.md +++ b/plugins/opencad-python/PLUGIN.md @@ -155,6 +155,36 @@ renaming or deleting layer `0` or `Defpoints`, deleting or freezing the current layer, deleting an externally referenced layer, and modifying with no properties. Making a layer current is a setting, not an undo step. +### Text and dimension styles: `doc.text_styles`, `doc.dim_styles` + +```python +S, D = ocs.active_document.text_styles, ocs.active_document.dim_styles +S.create("Title", height=5, width_factor=0.8, oblique=15, font="romans", annotative=True) +S.modify("Title", height=6) +D.create("Metric", dimscale=2, dimtxt=3.5, dimtxsty="Title") # DimStyle field names +D.create("Metric2", copy_from="Metric", dimtxt=4) +S.rename("Title", "Heading") # entities, dimension styles and tables follow +S.set_current("Heading"); D.set_current("Metric") +D.delete("Metric2"); S["Heading"]["height"]; D.current["name"]; S.names() +``` + +Same guarantees as layers: host-validated, one undo step, a refusal raises +`RuntimeError` and changes nothing (making a style current is a setting). +Text properties: `height` (0 = each text chooses), `width_factor` (0-100), +`oblique` (degrees, within 85), `font`, `big_font`, `backward`, `upside_down`, +`vertical`, `annotative`; use `font="arial.ttf"` for a TrueType face, because +the codec does not persist a separate family name. Dimension styles use the +`DimStyle` field names (`dimscale`, `dimtxt`, `dimasz`, ...); records list all +of them. Handles, `xref_*` fields and `name` are host-managed and refused, unknown +fields and wrongly typed values are refused, `dimscale` and `dimtxt` must be +positive, and `dimtxsty` must name an existing text style. `Standard` is never +renamed or deleted; a style that is current or still referenced is never deleted; +a case-only rename is refused. Style operations act on the active document. + +Known persistence limits (cadcodec, see `docs/cadcodec-reader-gaps.md`): a DXF +save drops the backward/upside-down flags of a text style and does not restore a +dimension style's `dimtxsty` name (the handle link survives); DWG keeps both. + ### Historical command-replay experiment (not in the default build) The following records an earlier experiment. The bundled build does **not** diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index b14708080..61dfa2fa9 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -280,10 +280,128 @@ class _Layers: return self[name] +class _Styles: + """Common lookup, rename, delete and current-style handling for the text + and dimension style tables. Every change is validated by the host, is one + undo step (making a style current is a setting), and a refusal raises + `RuntimeError` without changing the drawing. `Standard` is never renamed + or deleted, and a style that is current or in use is never deleted.""" + + _KIND = None + _RECORDS = None + + def _records(self): + return getattr(ocs, self._RECORDS)() + + def __iter__(self): + return iter(self._records()) + + def __len__(self): + return len(self._records()) + + def __contains__(self, name): + wanted = str(name).strip().upper() + return any(r["name"].upper() == wanted for r in self._records()) + + def __getitem__(self, name): + wanted = str(name).strip().upper() + for record in self._records(): + if record["name"].upper() == wanted: + return record + raise KeyError(name) + + def get(self, name, default=None): + try: + return self[name] + except KeyError: + return default + + def names(self): + return [r["name"] for r in self._records()] + + @property + def current(self): + for record in self._records(): + if record["current"]: + return record + return None + + @current.setter + def current(self, name): + self.set_current(name) + + def _run(self, op, name, options=None): + ocs.style_operation(self._KIND, op, str(name), options) + + def rename(self, name, new_name): + self._run("rename", name, {"to": str(new_name)}) + return self[new_name] + + def delete(self, name): + self._run("delete", name) + + def set_current(self, name): + self._run("set_current", name) + return self[name] + + +class _TextStyles(_Styles): + """`ocs.active_document.text_styles`. Properties: `height` (0 = each text + chooses), `width_factor`, `oblique` (degrees, within 85), `font` (SHX file), + `big_font`, `backward`, `upside_down`, `vertical`, `annotative`. Give a + TrueType face as a font file name such as `font="arial.ttf"`: the codec does + not persist a separate TrueType family name, so it is not exposed.""" + + _KIND = "text" + _RECORDS = "text_style_records" + _KEYS = ("height", "width_factor", "oblique", "font", "big_font", + "backward", "upside_down", "vertical", "annotative") + + def _options(self, properties): + unknown = [k for k in properties if k not in self._KEYS] + if unknown: + raise TypeError("unknown text style propert%s: %s" % ("y" if len(unknown) == 1 else "ies", ", ".join(sorted(unknown)))) + return {k: v for k, v in properties.items() if v is not None} + + def create(self, name, **properties): + self._run("create", name, self._options(properties)) + return self[name] + + def modify(self, name, **properties): + self._run("modify", name, self._options(properties)) + return self[name] + + +class _DimStyles(_Styles): + """`ocs.active_document.dim_styles`. Records and properties use the DimStyle + field names (`dimscale`, `dimtxt`, `dimasz`, `dimtxsty` ...); handles and + xref fields are host-managed and refused. `create(..., copy_from="Other")` + starts from an existing style.""" + + _KIND = "dim" + _RECORDS = "dim_style_records" + + def _options(self, properties): + return {k: v for k, v in properties.items() if v is not None or k == "dimclrd_true_color"} + + def create(self, name, copy_from=None, **properties): + options = self._options(properties) + if copy_from is not None: + options["copy_from"] = str(copy_from) + self._run("create", name, options) + return self[name] + + def modify(self, name, **properties): + self._run("modify", name, self._options(properties)) + return self[name] + + class _Document: def __init__(self): self._pending = None self.layers = _Layers() + self.text_styles = _TextStyles() + self.dim_styles = _DimStyles() self.entities = _Entities(self) self.solids = _Solids(self) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 65d9c1ad2..42c5804d3 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -952,6 +952,7 @@ mod ocs { Ok(vm.ctx.new_list(output).into()) } + #[cfg(feature = "experimental-host-model")] fn layer_options( name: String, options: &rustpython_vm::builtins::PyDictRef, @@ -1022,6 +1023,7 @@ mod ocs { /// Layer-table change through the host: `op` is `create`, `modify`, /// `rename` (`options={"to": ...}`), `delete` (`options={"erase_objects": bool}`) /// or `set_current`. Returns the layer's handle. + #[cfg(feature = "experimental-host-model")] #[pyfunction] fn layer_operation(op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { use ocs_plugin_api::host::TableOperation; @@ -1056,7 +1058,220 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.layer_operation: {error}"))) } + /// Convert a Python value (None, bool, int, float, str, list/tuple, dict + /// with string keys) to JSON. + #[cfg(feature = "experimental-host-model")] + fn py_to_json(value: &PyObjectRef, vm: &VirtualMachine) -> PyResult { + use rustpython_vm::builtins::{PyDict, PyFloat, PyInt, PyList, PyStr, PyTuple}; + use rustpython_vm::AsObject; + if vm.is_none(value) { + return Ok(serde_json::Value::Null); + } + if let Ok(flag) = value.clone().try_into_value::(vm) { + if value.class().is(vm.ctx.types.bool_type) { + return Ok(serde_json::Value::Bool(flag)); + } + } + if value.downcast_ref::().is_some() { + return Ok(serde_json::Value::from(value.clone().try_into_value::(vm)?)); + } + if value.downcast_ref::().is_some() { + let number = value.clone().try_into_value::(vm)?; + return serde_json::Number::from_f64(number) + .map(serde_json::Value::Number) + .ok_or_else(|| vm.new_value_error("ocs: numbers must be finite".to_owned())); + } + if value.downcast_ref::().is_some() { + return Ok(serde_json::Value::String(value.clone().try_into_value::(vm)?)); + } + if let Some(list) = value.downcast_ref::() { + let items: Vec = list.borrow_vec().to_vec(); + return items.iter().map(|item| py_to_json(item, vm)).collect::>>().map(serde_json::Value::Array); + } + if let Some(tuple) = value.downcast_ref::() { + return tuple.iter().map(|item| py_to_json(item, vm)).collect::>>().map(serde_json::Value::Array); + } + if let Some(dict) = value.downcast_ref::() { + let mut map = serde_json::Map::new(); + for key in dict.keys_vec() { + let name = key.clone().try_into_value::(vm)?; + let item = dict.get_item(&*key, vm)?; + map.insert(name, py_to_json(&item, vm)?); + } + return Ok(serde_json::Value::Object(map)); + } + Err(vm.new_type_error("ocs: unsupported value in a style property".to_owned())) + } + + #[cfg(feature = "experimental-host-model")] + fn style_kind(kind: &str, vm: &VirtualMachine) -> PyResult { + match kind { + "text" => Ok(ocs_plugin_api::host::TableStyleKind::Text), + "dim" => Ok(ocs_plugin_api::host::TableStyleKind::Dim), + other => Err(vm.new_value_error(format!("ocs.style_operation: unknown style kind {other:?}"))), + } + } + + /// Text/dimension style change through the host. `kind` is `text` or `dim`; + /// `op` is `create`, `modify`, `rename` (`{"to": ...}`), `delete` or + /// `set_current`. Text options: `height`, `width_factor`, `oblique` + /// (degrees), `font`, `big_font`, `backward`, + /// `upside_down`, `vertical`, `annotative`. Dimension options are DimStyle + /// field names (plus `copy_from` on create). Returns the style's handle. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn style_operation(kind: String, op: String, name: String, options: PyObjectRef, vm: &VirtualMachine) -> PyResult { + use ocs_plugin_api::host::{TableOperation, TextStyleConfig}; + let style_kind = style_kind(&kind, vm)?; + let options = if vm.is_none(&options) { + vm.ctx.new_dict() + } else { + options.try_into_value::(vm)? + }; + let operation = match (op.as_str(), kind.as_str()) { + ("rename", _) => { + ensure_known_entity_keys(&options, "style rename", &["to"], vm)?; + let to = options + .get_item_opt("to", vm)? + .ok_or_else(|| vm.new_value_error("ocs: rename needs the new name".to_owned()))? + .try_into_value::(vm)?; + TableOperation::StyleRename { kind: style_kind, from: name, to } + } + ("delete", _) => { + ensure_known_entity_keys(&options, "style delete", &[], vm)?; + TableOperation::StyleDelete { kind: style_kind, name } + } + ("set_current", _) => TableOperation::StyleSetCurrent { kind: style_kind, name }, + ("create" | "modify", "text") => { + ensure_known_entity_keys( + &options, + "text style", + &["height", "width_factor", "oblique", "font", "big_font", "backward", "upside_down", "vertical", "annotative"], + vm, + )?; + let present = |key: &str| -> PyResult> { + Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v))) + }; + let number = |key: &str| -> PyResult> { + present(key)?.map(|v| py_number_to_f64(v, vm)).transpose() + }; + let text = |key: &str| -> PyResult> { + present(key)?.map(|v| v.try_into_value::(vm)).transpose() + }; + let flag = |key: &str| -> PyResult> { + present(key)?.map(|v| v.try_into_value::(vm)).transpose() + }; + let config = TextStyleConfig { + name, + height: number("height")?, + width_factor: number("width_factor")?, + oblique_angle: number("oblique")?.map(f64::to_radians), + font_file: text("font")?, + big_font_file: text("big_font")?, + backward: flag("backward")?, + upside_down: flag("upside_down")?, + vertical: flag("vertical")?, + annotative: flag("annotative")?, + ..Default::default() + }; + if op == "create" { + TableOperation::TextStyleCreate { config } + } else { + TableOperation::TextStyleModify { config } + } + } + ("create" | "modify", "dim") => { + let copy_from = if op == "create" { + options.get_item_opt("copy_from", vm)?.filter(|v| !vm.is_none(v)) + .map(|v| v.try_into_value::(vm)).transpose()? + } else { + None + }; + let properties = vm.ctx.new_dict(); + for key in options.keys_vec() { + if key.clone().try_into_value::(vm)? != "copy_from" { + properties.set_item(&*key, options.get_item(&*key, vm)?, vm)?; + } + } + let properties = py_to_json(&PyObjectRef::from(properties), vm)?.to_string(); + if op == "create" { + TableOperation::DimStyleCreate { name, copy_from, properties } + } else { + TableOperation::DimStyleModify { name, properties } + } + } + (other, _) => return Err(vm.new_value_error(format!("ocs.style_operation: unknown operation {other:?}"))), + }; + let result = host_ctx::with_host(|host| host.table_operation(operation)) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + result + .map(|handle| handle.value()) + .map_err(|error| vm.new_runtime_error(format!("ocs.style_operation: {error}"))) + } + + /// Every text style with its properties (`oblique` in degrees). + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn text_style_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let current = document.header.current_text_style_name.to_uppercase(); + document + .text_styles + .iter() + .map(|style| (style.clone(), style.name.to_uppercase() == current)) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let mut output = Vec::with_capacity(rows.len()); + for (style, is_current) in rows { + let dict = vm.ctx.new_dict(); + dict.set_item("handle", vm.new_pyobj(style.handle.value()), vm)?; + dict.set_item("name", vm.new_pyobj(style.name.clone()), vm)?; + dict.set_item("height", vm.new_pyobj(style.height), vm)?; + dict.set_item("width_factor", vm.new_pyobj(style.width_factor), vm)?; + dict.set_item("oblique", vm.new_pyobj(style.oblique_angle.to_degrees()), vm)?; + dict.set_item("font", vm.new_pyobj(style.font_file.clone()), vm)?; + dict.set_item("big_font", vm.new_pyobj(style.big_font_file.clone()), vm)?; + dict.set_item("backward", vm.new_pyobj(style.flags.backward), vm)?; + dict.set_item("upside_down", vm.new_pyobj(style.flags.upside_down), vm)?; + dict.set_item("vertical", vm.new_pyobj(style.is_vertical), vm)?; + dict.set_item("annotative", vm.new_pyobj(style.annotative), vm)?; + dict.set_item("current", vm.new_pyobj(is_current), vm)?; + output.push(dict.into()); + } + Ok(vm.ctx.new_list(output).into()) + } + + /// Every dimension style with all of its DimStyle fields plus `current`. + #[cfg(feature = "experimental-host-model")] + #[pyfunction] + fn dim_style_records(vm: &VirtualMachine) -> PyResult { + let rows = host_ctx::with_host(|host| { + let document = host.document(); + let current = document.header.current_dimstyle_name.to_uppercase(); + document + .dim_styles + .iter() + .map(|style| { + let mut value = serde_json::to_value(style).unwrap_or(serde_json::Value::Null); + if let Some(object) = value.as_object_mut() { + object.insert("current".to_owned(), (style.name.to_uppercase() == current).into()); + } + value + }) + .collect::>() + }) + .ok_or_else(|| vm.new_runtime_error("ocs: not running inside a PY_ command".to_owned()))?; + let output = rows + .into_iter() + .map(|value| snapshot_value_to_py(value, vm)) + .collect::>>()?; + Ok(vm.ctx.new_list(output).into()) + } + /// Every layer with its full properties, in table order. + #[cfg(feature = "experimental-host-model")] #[pyfunction] fn layer_records(vm: &VirtualMachine) -> PyResult { use acadrust::types::{LineWeight, Transparency}; diff --git a/plugins/opencad-python/tests/test_document_model.py b/plugins/opencad-python/tests/test_document_model.py index 334ad66ec..cdd7dd999 100644 --- a/plugins/opencad-python/tests/test_document_model.py +++ b/plugins/opencad-python/tests/test_document_model.py @@ -36,6 +36,9 @@ class DocumentModelTests(unittest.TestCase): poll_input=lambda token: {"status": "point", "point": [1, 2, 3]}, layer_records=lambda: [{"name": "0", "current": True}, {"name": "Walls", "current": False}], layer_operation=lambda op, name, options: self.calls.append((op, name, options)) or 9, + text_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Title", "Notes")], + dim_style_records=lambda: [{"name": n, "current": n == "Standard"} for n in ("Standard", "Metric")], + style_operation=lambda kind, op, name, options: self.calls.append((kind, op, name, options)) or 9, ) namespace = {"ocs": self.ocs} exec(MODEL.read_text(), namespace) @@ -72,6 +75,25 @@ class DocumentModelTests(unittest.TestCase): layers.create("New", colour=1) self.assertEqual(len(self.calls), 2) + def test_text_and_dim_styles(self): + text, dim = self.doc.text_styles, self.doc.dim_styles + self.assertEqual(text.current["name"], "Standard") + self.assertEqual(text["TITLE"]["name"], "Title") + text.create("Notes", height=2.5, font=None, oblique=10) + self.assertEqual(self.calls[-1], ("text", "create", "Notes", {"height": 2.5, "oblique": 10})) + with self.assertRaises(TypeError): + text.create("Bad", true_type_font="Arial") + with self.assertRaises(TypeError): + text.modify("Title", fnt="x") + dim.create("Metric", copy_from="Standard", dimscale=2) + self.assertEqual(self.calls[-1], ("dim", "create", "Metric", {"dimscale": 2, "copy_from": "Standard"})) + dim.modify("Standard", dimtxt=3) + self.assertEqual(self.calls[-1], ("dim", "modify", "Standard", {"dimtxt": 3})) + dim.delete("Metric") + self.assertEqual(self.calls[-1], ("dim", "delete", "Metric", None)) + dim.set_current("Standard") + self.assertEqual(self.calls[-1], ("dim", "set_current", "Standard", None)) + def test_create_and_delete_use_document_model(self): line = self.doc.create_entity("Line", start={"x": 0, "y": 0, "z": 0}, end={"x": 10, "y": 0, "z": 0}) diff --git a/src/app/mod.rs b/src/app/mod.rs index 6714e04df..16b77c3cb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -35,7 +35,7 @@ mod record_api; pub(crate) mod settings; mod shortcuts; mod startup; -mod style_ops; +pub(crate) mod style_ops; mod text_inline; mod tolerance_dialog; mod update; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index fc7ab29d0..163d6d43d 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -1245,9 +1245,170 @@ impl<'a> HostSession<'a> { self.app.set_current_layer_name(self.tab, &stored)?; Ok(handle) } + other => self.style_operation(other), } } + /// Text and dimension style operations. They act on the active document + /// because the shared style machinery (in-use scan, rename, ribbon sync) is + /// keyed by the active tab. + fn style_operation(&mut self, operation: ocs_plugin_api::host::TableOperation) -> Result { + use crate::app::style_ops::StyleKind; + use ocs_plugin_api::host::{TableOperation, TableStyleKind}; + if self.tab != self.app.active_tab { + return Err("style operations act on the active document; switch to it first".to_owned()); + } + let kind_of = |kind: TableStyleKind| match kind { + TableStyleKind::Text => StyleKind::Text, + TableStyleKind::Dim => StyleKind::Dim, + }; + let stored = |doc: &CadDocument, kind: TableStyleKind, name: &str| -> Result<(String, Handle), String> { + let found = match kind { + TableStyleKind::Text => doc.text_styles.get(name).map(|s| (s.name.clone(), s.handle)), + TableStyleKind::Dim => doc.dim_styles.get(name).map(|s| (s.name.clone(), s.handle)), + }; + found.ok_or_else(|| format!("{} style {name:?} does not exist", match kind { + TableStyleKind::Text => "text", + TableStyleKind::Dim => "dimension", + })) + }; + match operation { + TableOperation::TextStyleCreate { config } => { + let name = validated_symbol_name(&config.name, "a text style name")?; + if self.app.style_exists(StyleKind::Text, &name) { + return Err(format!("text style {name:?} already exists")); + } + let mut style = acadrust::tables::TextStyle::new(name.clone()); + apply_text_style_config(&mut style, &config)?; + self.push_undo("Create text style"); + let handle = self.document_mut().allocate_handle(); + style.handle = handle; + self.document_mut().text_styles.add(style).map_err(|e| format!("the text style could not be created: {e}"))?; + self.finish_style_change(StyleKind::Text); + Ok(handle) + } + TableOperation::TextStyleModify { config } => { + let (name, handle) = stored(self.document(), TableStyleKind::Text, config.name.trim())?; + let mut style = self.document().text_styles.get(&name).cloned().expect("checked above"); + let before = style.clone(); + if config == (ocs_plugin_api::host::TextStyleConfig { name: config.name.clone(), ..Default::default() }) { + return Err("no text style properties to change".to_owned()); + } + apply_text_style_config(&mut style, &config)?; + if style == before { + return Ok(handle); + } + self.push_undo("Modify text style"); + if let Some(slot) = self.document_mut().text_styles.get_mut(&name) { + *slot = style; + } + self.finish_style_change(StyleKind::Text); + Ok(handle) + } + TableOperation::DimStyleCreate { name, copy_from, properties } => { + let name = validated_symbol_name(&name, "a dimension style name")?; + if self.app.style_exists(StyleKind::Dim, &name) { + return Err(format!("dimension style {name:?} already exists")); + } + let base = match ©_from { + Some(source) => { + let (source, _) = stored(self.document(), TableStyleKind::Dim, source.trim())?; + self.document().dim_styles.get(&source).cloned().expect("checked above") + } + None => acadrust::tables::DimStyle::new(name.clone()), + }; + let mut style = apply_dim_style_json(self.document(), &base, &properties)?; + self.push_undo("Create dimension style"); + let handle = self.document_mut().allocate_handle(); + style.handle = handle; + style.name = name; + self.document_mut().dim_styles.add(style).map_err(|e| format!("the dimension style could not be created: {e}"))?; + self.finish_style_change(StyleKind::Dim); + Ok(handle) + } + TableOperation::DimStyleModify { name, properties } => { + let (name, handle) = stored(self.document(), TableStyleKind::Dim, name.trim())?; + let base = self.document().dim_styles.get(&name).cloned().expect("checked above"); + let style = apply_dim_style_json(self.document(), &base, &properties)?; + if style == base { + return Ok(handle); + } + self.push_undo("Modify dimension style"); + if let Some(slot) = self.document_mut().dim_styles.get_mut(&name) { + *slot = style; + } + self.finish_style_change(StyleKind::Dim); + Ok(handle) + } + TableOperation::StyleRename { kind, from, to } => { + let (from, handle) = stored(self.document(), kind, from.trim())?; + if from.eq_ignore_ascii_case("Standard") { + return Err("the Standard style cannot be renamed".to_owned()); + } + let to = validated_symbol_name(&to, "a style name")?; + if from.eq_ignore_ascii_case(&to) { + return Err("the new style name matches the current one (a case-only change is not supported)".to_owned()); + } + if self.app.style_exists(kind_of(kind), &to) { + return Err(format!("style {to:?} already exists")); + } + self.push_undo("Rename style"); + self.app.rename_style_storage(kind_of(kind), &from, &to); + if kind == TableStyleKind::Text { + // A dimension style names its text style; follow the rename. + for dim in self.document_mut().dim_styles.iter_mut() { + if dim.dimtxsty.eq_ignore_ascii_case(&from) { + dim.dimtxsty = to.clone(); + } + } + } + self.finish_style_change(kind_of(kind)); + Ok(handle) + } + TableOperation::StyleDelete { kind, name } => { + let (name, handle) = stored(self.document(), kind, name.trim())?; + if name.eq_ignore_ascii_case("Standard") { + return Err("the Standard style cannot be deleted".to_owned()); + } + if self.app.style_in_use(kind_of(kind), &name) { + return Err(format!("style {name:?} is current or still in use")); + } + self.push_undo("Delete style"); + if !self.app.remove_style_storage(kind_of(kind), &name) { + return Err("the style could not be deleted".to_owned()); + } + self.finish_style_change(kind_of(kind)); + Ok(handle) + } + TableOperation::StyleSetCurrent { kind, name } => { + let (name, handle) = stored(self.document(), kind, name.trim())?; + let header = &mut self.document_mut().header; + match kind { + TableStyleKind::Text => { + header.current_text_style_handle = handle; + header.current_text_style_name = name.clone(); + self.app.ribbon.active_text_style = name; + } + TableStyleKind::Dim => { + header.current_dimstyle_handle = handle; + header.current_dimstyle_name = name.clone(); + self.app.ribbon.active_dim_style = name; + } + } + self.app.tabs[self.tab].dirty = true; + Ok(handle) + } + _ => Err("unsupported table operation".to_owned()), + } + } + + fn finish_style_change(&mut self, kind: crate::app::style_ops::StyleKind) { + self.app.tabs[self.tab].dirty = true; + self.app.after_style_change(kind); + self.app.tabs[self.tab].scene.bump_geometry(); + self.publish_document_view(); + } + fn finish_layer_change(&mut self, names: &[String]) { self.app.tabs[self.tab].dirty = true; self.app.tabs[self.tab].scene.invalidate_layer_dependencies(names); @@ -1320,19 +1481,125 @@ impl<'a> HostSession<'a> { /// A layer name AutoCAD would accept: 1-255 characters, none of `<>/\":;?*|=\``. fn validated_layer_name(raw: &str) -> Result { + validated_symbol_name(raw, "a layer name") +} + +/// A symbol-table name AutoCAD would accept (layers, styles): 1-255 characters, +/// none of `<>/\":;?*|=` or a backquote, and no control characters. +fn validated_symbol_name(raw: &str, what: &str) -> Result { let name = raw.trim(); if name.is_empty() { - return Err("a layer name cannot be empty".to_owned()); + return Err(format!("{what} cannot be empty")); } if name.chars().count() > 255 { - return Err("a layer name is limited to 255 characters".to_owned()); + return Err(format!("{what} is limited to 255 characters")); } if let Some(bad) = name.chars().find(|c| "<>/\\\":;?*|=`".contains(*c) || c.is_control()) { - return Err(format!("a layer name cannot contain {bad:?}")); + return Err(format!("{what} cannot contain {bad:?}")); } Ok(name.to_owned()) } +fn apply_text_style_config( + style: &mut acadrust::tables::TextStyle, + config: &ocs_plugin_api::host::TextStyleConfig, +) -> Result<(), String> { + let text = |what: &str, value: &str| { + if value.chars().any(|c| c.is_control()) || value.chars().count() > 255 { + Err(format!("{what} must be text of at most 255 characters")) + } else { + Ok(value.trim().to_owned()) + } + }; + if let Some(height) = config.height { + if !height.is_finite() || height < 0.0 { + return Err("text style height must be finite and not negative (0 = variable)".to_owned()); + } + style.height = height; + } + if let Some(width) = config.width_factor { + if !width.is_finite() || width <= 0.0 || width > 100.0 { + return Err("text style width factor must be greater than 0 and at most 100".to_owned()); + } + style.width_factor = width; + } + if let Some(angle) = config.oblique_angle { + if !angle.is_finite() || angle.abs() > 85f64.to_radians() + 1e-9 { + return Err("text style oblique angle must be within 85 degrees either way".to_owned()); + } + style.oblique_angle = angle; + } + if let Some(font) = &config.font_file { + style.font_file = text("font file", font)?; + } + if let Some(font) = &config.big_font_file { + style.big_font_file = text("big font file", font)?; + } + if let Some(font) = &config.true_type_font { + style.true_type_font = text("TrueType font", font)?; + } + if let Some(value) = config.backward { + style.flags.backward = value; + } + if let Some(value) = config.upside_down { + style.flags.upside_down = value; + } + if let Some(value) = config.vertical { + style.is_vertical = value; + } + if let Some(value) = config.annotative { + style.annotative = value; + } + if style.font_file.is_empty() && style.true_type_font.is_empty() { + return Err("a text style needs a font file or a TrueType font".to_owned()); + } + Ok(()) +} + +/// Apply a JSON object of DimStyle fields on top of `base`. Handles and xref +/// fields belong to the host and the name is set separately, so those keys are +/// refused; unknown keys and wrongly typed values are refused too. `dimtxsty` +/// must name an existing text style, whose handle is linked here. +fn apply_dim_style_json( + doc: &CadDocument, + base: &acadrust::tables::DimStyle, + properties: &str, +) -> Result { + let patch: serde_json::Value = serde_json::from_str(properties) + .map_err(|e| format!("dimension style properties are not valid JSON: {e}"))?; + let serde_json::Value::Object(patch) = patch else { + return Err("dimension style properties must be an object".to_owned()); + }; + let mut value = serde_json::to_value(base).map_err(|e| e.to_string())?; + let object = value.as_object_mut().ok_or("dimension style did not serialize to an object")?; + for (key, new) in patch { + if key == "handle" || key == "name" || key.starts_with("xref_") || key.ends_with("_handle") { + return Err(format!("dimension style property {key:?} is managed by the host")); + } + if !object.contains_key(&key) { + return Err(format!("unknown dimension style property {key:?}")); + } + object.insert(key, new); + } + let mut style: acadrust::tables::DimStyle = serde_json::from_value(value) + .map_err(|e| format!("invalid dimension style value: {e}"))?; + if !(style.dimscale.is_finite() && style.dimscale > 0.0) { + return Err("dimscale must be greater than zero".to_owned()); + } + if !(style.dimtxt.is_finite() && style.dimtxt > 0.0) { + return Err("dimtxt (text height) must be greater than zero".to_owned()); + } + if !(style.dimasz.is_finite() && style.dimasz >= 0.0) { + return Err("dimasz (arrow size) cannot be negative".to_owned()); + } + let Some(text_style) = doc.text_styles.get(&style.dimtxsty) else { + return Err(format!("dimtxsty {:?} is not a text style in this drawing", style.dimtxsty)); + }; + style.dimtxsty = text_style.name.clone(); + style.dimtxsty_handle = text_style.handle; + Ok(style) +} + /// The stored spelling of `name` in the drawing's linetype table, loading the /// standard linetypes first when it is not there yet. `None` when the drawing /// cannot supply it: a layer must not reference a linetype that does not exist. @@ -6392,6 +6659,161 @@ L.delete('Structure', erase_objects=True) assert!(document.entities().any(|e| matches!(e, EntityType::Line(_))), "undo restored the line"); } + /// Text and dimension styles through Python over the real runner: create, + /// modify, copy, rename (references follow), delete and make current, with + /// refusals that change nothing, DWG/DXF persistence and one-step undo. + #[test] + fn audit_python_text_and_dim_styles_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + let process = ocs_plugin_api::process::PluginProcess::spawn( + std::path::Path::new(&plugin_path), &mut host, crate::plugin::v4_support::notification_handler(), + ).unwrap(); + let dir = std::env::temp_dir(); + let run = |host: &mut HostSession<'_>, tag: &str, body: &str| { + let script = dir.join(format!("ocs_styles_{tag}_{}.py", std::process::id())); + std::fs::write(&script, format!(concat!( + "doc = ocs.active_document\nS = doc.text_styles\nD = doc.dim_styles\nL = doc.layers\n", + "refused, accepted = [], []\n", + "def check(tag, fn):\n", + " try:\n fn()\n except (RuntimeError, TypeError, ValueError):\n refused.append(tag)\n", + " else:\n accepted.append(tag)\n", + "try:\n{body}\n", + "except Exception as error:\n", + " accepted.append('SCRIPTERROR_' + ''.join(c if c.isalnum() else '_' for c in str(error))[:120])\n", + "L.create('REPORT ' + str(len(refused)) + ' ~ ' + ' '.join(accepted))\n", + ), body = body.lines().map(|line| format!(" {line}")).collect::>().join("\n"))).unwrap(); + assert!(process.dispatch(host, &format!("PY_RUN {}", script.display()), &mut |_| {}).unwrap()); + let _ = std::fs::remove_file(&script); + }; + let report = |document: &CadDocument| -> (usize, Vec) { + let name = document.layers.iter().map(|l| l.name.clone()).filter(|n| n.starts_with("REPORT")).last() + .expect("script report layer"); + let (refused, accepted) = name["REPORT".len()..].split_once('~').unwrap(); + (refused.trim().parse().unwrap(), accepted.split_whitespace().map(str::to_owned).collect()) + }; + + run(&mut host, "build", r#" +S.create('Title', height=5, width_factor=0.8, oblique=15, font='romans', big_font='bigfont', backward=True, annotative=True) +S.create('Notes', font='arial.ttf') +S.modify('Title', height=6, upside_down=True) +D.create('Metric', dimscale=2, dimtxt=3.5, dimasz=2.5, dimtxsty='Title') +D.create('Metric2', copy_from='Metric', dimtxt=4) +D.modify('Metric', dimscale=3) +S.rename('Notes', 'Remarks') +S.rename('Title', 'Heading') +check('t_dup', lambda: S.create('heading')) +check('t_badname', lambda: S.create('a|b')) +check('t_height', lambda: S.create('T1', height=-1)) +check('t_width0', lambda: S.create('T2', width_factor=0)) +check('t_oblique', lambda: S.create('T3', oblique=89)) +check('t_nofont', lambda: S.create('T4', font='')) +check('t_unknown', lambda: S.create('T5', fnt='x')) +check('t_modmissing', lambda: S.modify('Nope', height=1)) +check('t_modnone', lambda: S.modify('Heading')) +check('t_renameStandard', lambda: S.rename('Standard', 'Std2')) +check('t_rename_case', lambda: S.rename('Heading', 'HEADING')) +check('t_rename_clash', lambda: S.rename('Remarks', 'heading')) +check('t_delStandard', lambda: S.delete('Standard')) +check('t_delInUse', lambda: S.delete('Heading')) +check('t_current_missing', lambda: S.set_current('Nope')) +check('d_dup', lambda: D.create('metric')) +check('d_unknown', lambda: D.create('D1', dimbogus=1)) +check('d_handle', lambda: D.create('D2', dimtxsty_handle=5)) +check('d_name', lambda: D.modify('Metric', name='X')) +check('d_type', lambda: D.create('D3', dimscale='big')) +check('d_scale0', lambda: D.create('D4', dimscale=0)) +check('d_txt', lambda: D.create('D5', dimtxt=-1)) +check('d_txsty', lambda: D.create('D6', dimtxsty='Nope')) +check('d_copy_missing', lambda: D.create('D7', copy_from='Nope')) +check('d_delStandard', lambda: D.delete('Standard')) +check('d_modmissing', lambda: D.modify('Nope', dimscale=2)) +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "accepted invalid requests: {accepted:?}"); + assert_eq!(refused, 26); + + let check_state = |label: &str, document: &CadDocument| { + assert!(document.text_styles.get("Title").is_none() && document.text_styles.get("Notes").is_none(), "{label}: old names gone"); + let title = document.text_styles.get("Heading").unwrap_or_else(|| panic!("{label}: Heading missing")); + assert_eq!(title.height, 6.0, "{label}"); + assert!((title.width_factor - 0.8).abs() < 1e-9, "{label}"); + assert!((title.oblique_angle - 15f64.to_radians()).abs() < 1e-6, "{label}: oblique {}", title.oblique_angle); + assert_eq!(title.font_file.to_lowercase(), "romans", "{label}"); + assert_eq!(title.big_font_file.to_lowercase(), "bigfont", "{label}"); + if label == "DXF" { + // BLOCKER (cadcodec): the DXF STYLE writer hard-codes group 71 to 0, so the + // backward and upside-down generation flags are lost on a DXF save. + assert!(!title.flags.backward && !title.flags.upside_down, "{label}: cadcodec now keeps the flags; flip this canary"); + } else { + assert!(title.flags.backward && title.flags.upside_down, "{label}"); + } + let remarks = document.text_styles.get("Remarks").unwrap_or_else(|| panic!("{label}: Remarks missing")); + assert_eq!(remarks.font_file.to_lowercase(), "arial.ttf", "{label}: {remarks:?}"); + let metric = document.dim_styles.get("Metric").unwrap_or_else(|| panic!("{label}: Metric missing")); + assert_eq!((metric.dimscale, metric.dimtxt, metric.dimasz), (3.0, 3.5, 2.5), "{label}"); + if label == "DXF" { + // BLOCKER (cadcodec): the DXF DIMSTYLE reader keeps only the text-style handle + // (group 340) and never resolves `dimtxsty` from it, so the name reopens as + // "Standard". The handle survives and identifies the right style. + assert_eq!(metric.dimtxsty, "Standard", "{label}: cadcodec now resolves the name; flip this canary"); + assert_eq!(metric.dimtxsty_handle, title.handle, "{label}: text style link by handle"); + } else { + assert_eq!(metric.dimtxsty, "Heading", "{label}: dimtxsty followed the rename"); + } + let copy = document.dim_styles.get("Metric2").unwrap_or_else(|| panic!("{label}: Metric2 missing")); + assert_eq!((copy.dimscale, copy.dimtxt), (2.0, 4.0), "{label}: copied before Metric changed"); + }; + check_state("live", host.document()); + let live_title = host.document().text_styles.get("Heading").unwrap().clone(); + assert!(live_title.annotative, "annotative is kept in the live style"); + assert_eq!(host.document().dim_styles.get("Metric").unwrap().dimtxsty_handle, live_title.handle, "dim style links the text style handle"); + let dwg = crate::io::load_bytes("styles.dwg", acadrust::DwgWriter::write_to_vec(host.document()).unwrap()).unwrap(); + let dxf = crate::io::load_bytes("styles.dxf", acadrust::DxfWriter::new(host.document()).write_to_vec().unwrap()).unwrap(); + check_state("DWG", &dwg); + check_state("DXF", &dxf); + + run(&mut host, "destroy", r#" +S.set_current('Remarks') +D.set_current('Metric2') +check('t_delcurrent', lambda: S.delete('Remarks')) +check('d_delcurrent', lambda: D.delete('Metric2')) +check('d_delInUseByCurrentOnly', lambda: D.delete('Metric2')) +S.set_current('Standard') +D.set_current('Standard') +S.delete('Remarks') +D.delete('Metric2') +D.delete('Metric') +S.delete('Heading') +"#); + let (refused, accepted) = report(host.document()); + assert!(accepted.is_empty(), "{accepted:?}"); + assert_eq!(refused, 3); + let document = host.document(); + for name in ["Heading", "Remarks"] { + assert!(document.text_styles.get(name).is_none(), "{name} deleted"); + } + for name in ["Metric", "Metric2"] { + assert!(document.dim_styles.get(name).is_none(), "{name} deleted"); + } + assert_eq!(document.header.current_text_style_name, "Standard"); + assert_eq!(document.header.current_dimstyle_name, "Standard"); + + // U: the final report layer and the four deletes are five steps; undoing + // them all brings the styles back. + drop(process); + drop(host); + app.finish_pending_history(0); + app.undo_steps(5); + let document = &app.tabs[0].scene.document; + assert!(document.text_styles.get("Heading").is_some() && document.text_styles.get("Remarks").is_some(), "undo restored the text styles"); + assert!(document.dim_styles.get("Metric").is_some() && document.dim_styles.get("Metric2").is_some(), "undo restored the dimension styles"); + } + #[test] fn audit_python_raster_image_definition_linkage_over_real_ipc() { use acadrust::objects::ObjectType; diff --git a/src/app/style_ops.rs b/src/app/style_ops.rs index e2ec1a2f2..dc645bef1 100644 --- a/src/app/style_ops.rs +++ b/src/app/style_ops.rs @@ -271,7 +271,7 @@ impl OpenCADStudio { } } - fn style_exists(&self, kind: StyleKind, name: &str) -> bool { + pub(super) fn style_exists(&self, kind: StyleKind, name: &str) -> bool { self.style_names(kind) .iter() .any(|n| n.eq_ignore_ascii_case(name)) @@ -308,7 +308,7 @@ impl OpenCADStudio { /// Refresh anything that mirrors the style list / current style after a /// mutation (ribbon dropdowns, geometry that depends on the style). - fn after_style_change(&mut self, kind: StyleKind) { + pub(super) fn after_style_change(&mut self, kind: StyleKind) { if kind.in_ribbon() { self.sync_ribbon_styles(); } @@ -405,7 +405,7 @@ impl OpenCADStudio { false } - fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool { + pub(super) fn remove_style_storage(&mut self, kind: StyleKind, name: &str) -> bool { let doc = &mut self.tabs[self.active_tab].scene.document; match kind { StyleKind::Text => doc.text_styles.remove(name).is_some(), @@ -541,7 +541,7 @@ impl OpenCADStudio { /// Rename `old`→`new` in the backing store, re-keying table entries and /// rewriting name-based references + current-style pointers. - fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) { + pub(super) fn rename_style_storage(&mut self, kind: StyleKind, old: &str, new: &str) { let i = self.active_tab; match kind { StyleKind::Text => {