feat(python): text and dimension styles (doc.text_styles, doc.dim_styles)

Extends TableOperation with text style create/modify, dimension style
create/modify (DimStyle fields as a validated JSON object), rename, delete and
set-current. Reuses OCS's style in-use scan and reference-following rename
(four style_ops methods widened to pub(super)); a renamed text style also
updates dimension styles that name it. Real-IPC audit with DWG/DXF persistence
and undo, IPC and Python unit tests, docs. Four cadcodec findings are pinned by
canaries.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-21 16:18:22 +02:00
1 parent b7b6e2cef5
commit aaea15e4ef
11 files changed
+894 -8

No files matched your search

+43
View File
@@ -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<String>, 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<f64>,
pub width_factor: Option<f64>,
pub oblique_angle: Option<f64>,
pub font_file: Option<String>,
pub big_font_file: Option<String>,
pub true_type_font: Option<String>,
pub backward: Option<bool>,
pub upside_down: Option<bool>,
pub vertical: Option<bool>,
pub annotative: Option<bool>,
}
/// A kernel operation on ACIS-backed solids (API v7, additive). The host owns
@@ -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::<PluginRequest>(&bytes).unwrap(),
+14
View File
@@ -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.
+13
View File
@@ -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.
+30
View File
@@ -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**
@@ -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)
+215
View File
@@ -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<u64> {
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<serde_json::Value> {
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::<bool>(vm) {
if value.class().is(vm.ctx.types.bool_type) {
return Ok(serde_json::Value::Bool(flag));
}
}
if value.downcast_ref::<PyInt>().is_some() {
return Ok(serde_json::Value::from(value.clone().try_into_value::<i64>(vm)?));
}
if value.downcast_ref::<PyFloat>().is_some() {
let number = value.clone().try_into_value::<f64>(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::<PyStr>().is_some() {
return Ok(serde_json::Value::String(value.clone().try_into_value::<String>(vm)?));
}
if let Some(list) = value.downcast_ref::<PyList>() {
let items: Vec<PyObjectRef> = list.borrow_vec().to_vec();
return items.iter().map(|item| py_to_json(item, vm)).collect::<PyResult<Vec<_>>>().map(serde_json::Value::Array);
}
if let Some(tuple) = value.downcast_ref::<PyTuple>() {
return tuple.iter().map(|item| py_to_json(item, vm)).collect::<PyResult<Vec<_>>>().map(serde_json::Value::Array);
}
if let Some(dict) = value.downcast_ref::<PyDict>() {
let mut map = serde_json::Map::new();
for key in dict.keys_vec() {
let name = key.clone().try_into_value::<String>(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<ocs_plugin_api::host::TableStyleKind> {
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<u64> {
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::<rustpython_vm::builtins::PyDictRef>(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::<String>(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<Option<PyObjectRef>> {
Ok(options.get_item_opt(key, vm)?.filter(|v| !vm.is_none(v)))
};
let number = |key: &str| -> PyResult<Option<f64>> {
present(key)?.map(|v| py_number_to_f64(v, vm)).transpose()
};
let text = |key: &str| -> PyResult<Option<String>> {
present(key)?.map(|v| v.try_into_value::<String>(vm)).transpose()
};
let flag = |key: &str| -> PyResult<Option<bool>> {
present(key)?.map(|v| v.try_into_value::<bool>(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::<String>(vm)).transpose()?
} else {
None
};
let properties = vm.ctx.new_dict();
for key in options.keys_vec() {
if key.clone().try_into_value::<String>(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<PyObjectRef> {
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::<Vec<_>>()
})
.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<PyObjectRef> {
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::<Vec<_>>()
})
.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::<PyResult<Vec<_>>>()?;
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<PyObjectRef> {
use acadrust::types::{LineWeight, Transparency};
@@ -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})
+1 -1
View File
@@ -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;
+425 -3
View File
@@ -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<Handle, String> {
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 &copy_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<String, String> {
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<String, String> {
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<acadrust::tables::DimStyle, String> {
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::<Vec<_>>().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<String>) {
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;
+4 -4
View File
@@ -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 => {