From 5e6a8307829db17db9ae1d5081c842e48cfefad9 Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Sun, 20 Sep 2026 18:12:31 +0200 Subject: [PATCH] Complete Ole2Frame: embed a picture file from a script EmbedPicture builds the frame with OCS's own EmbeddedImage and build_embedded_ole, so the opaque storage is never scripted. PNG, JPEG and BMP are stored as read and other formats are re-encoded as PNG. The lifecycle test now creates through Python; a new test covers formats, aspect, origin, layer and refusals. Co-Authored-By: Claude Sonnet 5 --- crates/ocs_plugin_api/src/host.rs | 5 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 1 + docs/plugin-host-model-completion-plan.md | 7 +- docs/plugin-host-model-coverage-ledger.md | 4 +- plugins/opencad-python/entity_manifest.json | 2 +- plugins/opencad-python/src/document_model.py | 7 ++ plugins/opencad-python/src/ocs_module.rs | 22 ++++ src/app/plugin_host.rs | 107 ++++++++++++++++++- 8 files changed, 148 insertions(+), 7 deletions(-) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 3a18d1fb0..f95c2880a 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -109,6 +109,11 @@ pub enum SolidOperation { /// layer. The kernel refuses some cases (coincident faces, cuts it has no /// closed form for); a refusal changes nothing. Curved operands can take a /// second or two, and the host thread is busy meanwhile. + /// Embed a picture file as an OLE frame whose bottom-left corner is at + /// `origin`, `width` drawing units wide, keeping the picture's aspect. + /// PNG, JPEG and BMP are stored as read; other formats are re-encoded as + /// PNG. The frame goes on `layer` (layer `0` when `None`). + EmbedPicture { path: String, origin: [f64; 3], width: f64, layer: Option }, Boolean { first: Handle, second: Handle, diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 8662e4888..3e448ea89 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -115,6 +115,7 @@ mod tests { }, SolidOperation::Transform { handle: acadrust::Handle::new(7), matrix: [1.0; 16] }, SolidOperation::RegionFromProfile { source: acadrust::Handle::new(3), layer: None, delete_source: true }, + SolidOperation::EmbedPicture { path: "p.png".into(), origin: [0.0; 3], width: 5.0, layer: None }, SolidOperation::Boolean { first: acadrust::Handle::new(1), second: acadrust::Handle::new(2), diff --git a/docs/plugin-host-model-completion-plan.md b/docs/plugin-host-model-completion-plan.md index 4ea89d902..d764823ee 100644 --- a/docs/plugin-host-model-completion-plan.md +++ b/docs/plugin-host-model-completion-plan.md @@ -7,9 +7,9 @@ Build a general Python document model over the OCS host API for all **43 canvas As of 20 September 2026, the working branch is `plugin/host-model-api` at `a8dd3640` plus the Leader increment, published as draft PR [#1391](https://github.com/HakanSeven12/OpenCADStudio/pull/1391). Host API is -v7. The Python feature has explicit writes for **43 of 43** canvas kinds: 40 -are creatable (the legacy `Polyline`, `Ole2Frame` and `Body` are update-only). This is **not** 41 completed -kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region and Surface pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. +v7. The Python feature has explicit writes for **43 of 43** canvas kinds: 41 +are creatable (the legacy `Polyline` and `Body` are update-only). This is **not** 41 completed +kinds: Tolerance, Shape, AttributeEntity, Hatch, MLine, Dimension, MultiLeader, Table, PolygonMesh, PolyfaceMesh, Mesh, Wipeout, Point, Line, Circle, Arc, Ellipse, LwPolyline, Spline, Text, MText, Ray, XLine, Solid, Face3D, Solid3D, Region, Surface and Ole2Frame pass every completion gate; Insert now passes every gate too; the audited Polyline (update-only by decision), Polyline2D and Polyline3D pass every gate that their format allows. AttributeDefinition passes its host and real IPC lifecycle but remains short of `Complete` because the previously pinned CAD codec dropped optional ATTDEF fields during DXF reads; revalidate that blocker against the current @@ -183,3 +183,4 @@ exact queue above and update statuses with evidence after each increment. - Timed booleans in a release build (details in [cadkernel-body-path.md](cadkernel-body-path.md)). A curved box-and-cylinder boolean takes about 1 to 2.2 s, overlapping spheres 0.2 s and planar operands 1 to 2 ms, against 23 s in a debug build. The kernel refuses many curved cases quickly with a reason (`Coincident`, `CutRefused`, `NoClosedForm`), including a second cut in an already drilled box. Recommendation: add a synchronous `SolidOperation::Boolean` that surfaces the refusal and leaves the operands untouched, test it with planar operands plus one ignored curved case, and note the 30 s per-script budget. No code changed in this step. - Implemented `SolidOperation::Boolean` and `doc.solids.union`, `subtract` and `intersect`, as recommended by the timing study. It runs synchronously, surfaces the kernel's refusal (`Coincident`, `CutRefused`, `NoClosedForm`) with a plain-language reason and the words "nothing was changed", consumes the operands unless `keep_operands`, and records one undo step. The lifecycle test uses planar boxes (millisecond cost, exact volumes, debug-safe) and an `#[ignore]` test documents a curved refusal. With this the kernel operations in the cadkernel note are all built. SectionSymbol is the only unmapped kind. - Mapped `SectionSymbol`, the last unmapped kind, so all 43 canvas kinds now have explicit writes. The blocker recorded earlier was overstated: cadcodec documents `points` as canonical, says the counts equal the point count in every verified export, and provides `sync_display_fields()`, which OCS's own edits already call; OCS also renders a symbol with no style or view link. The host derives the counts and projections when `points` changes and validates that references are existing `SectionViewStyle` and `ViewRep` class objects. Every gate passes except DXF, where cadcodec reopens a top-level `SECTIONLINE` as an unknown entity (issue 5 in the cadcodec write-up, pinned by a canary). Still unverifiable here: the meaning of `raw_flags_90` and AutoCAD's acceptance of a symbol with no parent view. +- Completed `Ole2Frame` by adding creation. `SolidOperation::EmbedPicture` and `doc.embed_picture` build the frame from a picture file with OCS's own embedding code, so a script never touches the opaque storage; PNG, JPEG and BMP are stored byte-for-byte and other formats are re-encoded as PNG. The lifecycle test now creates through Python and the new creation-rules test covers formats, aspect, origin, layer and seven refusals. 30 kinds are complete. diff --git a/docs/plugin-host-model-coverage-ledger.md b/docs/plugin-host-model-coverage-ledger.md index efa7ebdc1..d072b7119 100644 --- a/docs/plugin-host-model-coverage-ledger.md +++ b/docs/plugin-host-model-coverage-ledger.md @@ -1,6 +1,6 @@ # OCS canvas kind coverage ledger -Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **43 of 43 canvas kinds have explicit geometry writes** (40 creatable, plus update-only legacy `Polyline`, `Ole2Frame` and `Body`), **29 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. +Use with [the completion plan](plugin-host-model-completion-plan.md). This ledger is current as of 20 September 2026: **43 of 43 canvas kinds have explicit geometry writes** (41 creatable, plus update-only legacy `Polyline` and `Body`), **30 kinds are complete**, and none remains layer-only. `Mapped` means at least one non-layer field is exposed for Python writes; it does **not** mean the kind passes the completion gate. `Update-only` means Python creation is unavailable. `Layer-only` means raw inspection and canvas layer edits exist, but geometry is not mapped. Record actual evidence or a blocking issue before changing any status to `Complete`. Completion gate codes: **C** = create through Python document model; **R** = read; **E** = edit defining geometry and another property; **D** = delete; **U** = undo/redo and atomic failure; **I** = real IPC plus GUI/canvas pick or observable behavior; **W** = DWG and DXF save/reopen; **V** = invalid-input checks and untouched-field/reference preservation; **P** = build and load the in-tree bundled plugin with a manifest matching the host toolchain and acadrust source. A kind is `Complete` only when **C/R/E/D/U/I/W/V/P** all pass and its property catalog matches implementation. `Blocked` requires a specific dependency or failing fixture. @@ -48,7 +48,7 @@ Completion gate codes: **C** = create through Python document model; **R** = rea | Body | **Integration-tested (rigid move only)** | Mapped read-only like Solid3D (same payload container). `audit_python_body_transform_over_real_ipc` moves a Body carrying a kernel box through `doc.solids.translate`, checks the exact extent, DWG reopen with a losslessly lifting payload and the exact volume, and the explanatory refusal of `create_entity('Body')`. Scripts cannot create a Body, and a non-solid (sheet or wire) body that does not lift is refused untouched. | | Solid3D | **Complete (create + rigid move)** | New in this session: an additive `HostApi::solid_operation` (`SolidOperation::Create` and `Transform`, plus a `SolidPrimitive` enum) carried by `PluginRequest::SolidOperation` / `PluginResponse::SolidResult` through both IPC clients, the server and `HostSession`, and a Python `doc.solids` API: `box`, `wedge`, `cylinder`, `sphere`, `torus`, `pyramid`, `transform`, `translate`, `rotate`, `mirror`. The host builds bodies with cadkernel, and every result goes through `solid_to_sat`, which lifts its own output back and refuses anything lossy. A transform first lifts the existing payload with `kernel_acis_body` and is refused, leaving the payload untouched, when that is lossy. Only rigid moves and mirrors are accepted (scaling and shear are refused). Regenerated `wires` follow the body, `silhouettes` are cleared and `history_handle` is dropped. Python sees `uid`, `point_of_reference` and `history_handle` read-only; `acis_data`, `wires` and `silhouettes` are excluded and preserved; creation through `create_entity` is refused with a message pointing at `doc.solids`. `audit_python_solid3d_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner with the kernel as oracle: all six primitives match their analytic volumes (box, wedge and cube exactly, curved solids within 2 percent of the tessellated volume), a translate, a rotate and a mirror keep the volume and move the extent exactly and keep 12 edge wires, 13 refusals (zero and non-finite sizes, a torus with minor above major, too few pyramid sides, an empty layer, an unknown primitive, a wrong number count, a scaling matrix, a non-finite offset, a payload the kernel cannot lift, a missing entity) change nothing and leave no undo step, DWG and DXF reopen with the payload lifting losslessly and the same volume and extent, a reopened payload moves again, and undo restores the original solid exactly. Not covered: solids that carry parametric history, and AutoCAD acceptance of the written payload, which cannot be tested here. **Booleans** (`SolidOperation::Boolean`, `doc.solids.union`, `subtract` and `intersect`) are covered by `audit_python_boolean_lifecycle_over_real_ipc`: two overlapping boxes give exact volumes (union 360, subtract and intersect 120) and extents, the operands are consumed unless `keep_operands` (undo restores both exactly), a result takes the first operand's layer or an explicit one, a second planar operation chains on the result (a through-hole gives 104), eight refusals change nothing and record no undo step (same operand twice, a non-solid, a missing entity, an unliftable payload, an empty layer, an unknown operation, a disjoint intersection), and DWG and DXF reopen losslessly with a reopened result moving again. The kernel refuses many curved cases (measured in release: about 1 to 2 s when it succeeds, and `Coincident`, `CutRefused` or `NoClosedForm` when it does not, including a second cut in a drilled box); its reason reaches the script and nothing changes, shown by an `#[ignore]` curved test that takes about 27 s in a debug build. Booleans block the host thread and count against the script's 30 s budget; only Solid3D operands are accepted. | | Surface | **Complete (plane surface, extruded open profile, isolines, rigid move)** | New `SolidOperation::SurfaceFromProfile` and `SolidOperation::Extrude`, exposed as `doc.solids.surface(profile)` and `doc.solids.extrude(profile, direction)`. A closed planar profile becomes a `Plane` surface (built as the region body, so it keeps the plane kind); a profile extruded along a nonzero direction becomes a Solid3D when it is closed and a generic Surface when it is open, using OCS's own `extrusion_body`. Every payload is verified lossless by `solid_to_sat`, and rigid moves (now accepted for surfaces too) keep a plane a plane and turn any other kind generic, as OCS does after an edit. Python sees `surface_kind`, `modeler_format_version`, `point_of_reference` and `history_handle` read-only and `u_isolines` and `v_isolines` writable (0 to 200); the payload, wires, silhouettes and the kind-specific `surface_data` are excluded and preserved. The kind field is exposed as `surface_kind` because a bare `kind` key names the entity type in every Python dict; the new `exposed_property_names_are_unique_per_kind` test guards against that class of collision. `audit_python_surface_lifecycle_over_real_ipc` passes C/R/E/D/U/I/W/V/P through the real runner: two plane surfaces (one on its own layer), a surface from an open polyline extrusion, three extruded solids checked against their analytic volumes (a box exactly, curved ones within 2 percent), `delete_source` erasing only the intended profile, canvas geometry, nine refusals that change nothing and record no undo step, an isoline transaction with four rejections (500, negative, the read-only kind, an excluded key), moves with exact extents, DWG and DXF reopen with lifting payloads, matching extents, kept isolines and the plane kind, a reopened surface moving again, and exact undo. Not covered: lofted, revolved, swept and NURBS surfaces (a script cannot create them; an existing one is preserved and turns generic on a move), parametric history, and AutoCAD acceptance. | -| Ole2Frame | **Update-only (all applicable gates pass)** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. Host validation requires finite corners spanning a nonzero width and height and `lock_aspect` of 0 or 1. Creation from a script is refused with an explanatory message, because a valid frame needs a real embedded object; the host's own `build_embedded_ole` builds one from an image. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver; the fixture embeds a real 4x3 PNG through `add_embedded_image`) passes E/D/U/I/W/V/P: a corner and aspect-lock transaction, canvas geometry, rejection of a zero-size frame, a bad aspect lock, non-finite corners, a read-only `version` and an attempt to write `storage`, an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and two-step undo/redo. Not `Complete` because Python cannot create a frame. A create path (`file_path` of an image, built with `build_embedded_ole`) would need a manual-kind converter like Dimension and is the natural follow-up. | +| Ole2Frame | **Complete** | Writable: `upper_left_corner`, `lower_right_corner`, `lock_aspect`, `is_paper_space`. Read-only: `version`, `source_application`, `ole_object_type`, `dwg_mode`. The embedded object `storage` and the frame `envelope` are opaque, excluded from the Python form, refused by the mutation check (`Ole2Frame embedded storage cannot change`) and preserved untouched. **Creation (new 2026-09-20):** `SolidOperation::EmbedPicture` and `doc.embed_picture(path, origin=(0, 0, 0), width=1, layer=None)` build a frame from a picture file with OCS's own `EmbeddedImage::from_file` and `build_embedded_ole`: PNG, JPEG and BMP are stored as read, any other format (a TIFF in the test) is re-encoded as PNG, `origin` is the bottom-left corner, `width` the frame width, and the height follows the picture's aspect. The generic `create_entity('Ole2Frame')` is refused with a message pointing at `doc.embed_picture`. Host validation requires finite corners spanning a nonzero size and `lock_aspect` of 0 or 1 for edits, and for creation a readable picture, a finite origin, a positive width and a non-empty layer. `audit_python_ole2frame_lifecycle_over_real_ipc` (shared driver) passes C/R/E/D/U/I/W/V/P through the real runner: creation from a PNG, a corner and aspect-lock transaction, canvas geometry, five rejection cases (including an attempt to write `storage`), an atomic rollback, **DWG and DXF reopen with the 119-byte embedded picture byte-for-byte intact (extracted through `extract_presentation`)**, re-edit, and three-step undo/redo. `audit_python_ole2frame_creation_rules_over_real_ipc` checks that PNG and JPEG are stored byte-for-byte, a TIFF becomes a PNG, the frame keeps the picture's 2:1 aspect with the requested origin and layer, and seven refusals (missing file, a non-image, empty layer, zero width, non-finite origin, a short origin list, and generic creation) change nothing and record no undo step. Not covered: OLE objects that are not pictures, and AutoCAD acceptance of the written frame, which cannot be tested here. | After each increment, update the row with test names, fixture paths, exact passing commands, the OCS commit hash, and any manual GUI result. Host and diff --git a/plugins/opencad-python/entity_manifest.json b/plugins/opencad-python/entity_manifest.json index 05d48d57b..766aa6cf1 100644 --- a/plugins/opencad-python/entity_manifest.json +++ b/plugins/opencad-python/entity_manifest.json @@ -789,7 +789,7 @@ }, "Ole2Frame": { "update_only": true, - "update_only_message": "Ole2Frame cannot be created from a script: its embedded object storage is opaque; existing frames can be moved, resized and aspect-locked, and the stored object is preserved untouched", + "update_only_message": "create an Ole2Frame with doc.embed_picture(path, origin, width); existing frames can be moved, resized and aspect-locked, and the stored object is preserved untouched", "fields": { "storage": { "exclude": true, diff --git a/plugins/opencad-python/src/document_model.py b/plugins/opencad-python/src/document_model.py index c50d58b41..ec5fd8ab1 100644 --- a/plugins/opencad-python/src/document_model.py +++ b/plugins/opencad-python/src/document_model.py @@ -220,6 +220,13 @@ class _Document: handle = entity.handle if isinstance(entity, _Entity) else int(entity) ocs.remove_entity(handle) + def embed_picture(self, path, origin=(0, 0, 0), width=1, layer=None): + """Embed a picture file (PNG, JPEG, BMP or another format re-encoded as + PNG) as an OLE frame. `origin` is the bottom-left corner and `width` + the frame width; the height follows the picture's aspect ratio.""" + handle = ocs.embed_picture(str(path), [float(v) for v in origin], float(width), layer) + return self.entities[handle] + def coverage(self, kind=None): if kind is not None: return ocs.entity_coverage(kind) diff --git a/plugins/opencad-python/src/ocs_module.rs b/plugins/opencad-python/src/ocs_module.rs index 2f860727d..e4c9ce43c 100644 --- a/plugins/opencad-python/src/ocs_module.rs +++ b/plugins/opencad-python/src/ocs_module.rs @@ -475,6 +475,28 @@ mod ocs { .map_err(|error| vm.new_runtime_error(format!("ocs.solid_region: {error}"))) } + /// Embed a picture file as an OLE frame. + #[pyfunction] + fn embed_picture( + path: String, + origin: Vec, + width: f64, + layer: Option, + vm: &VirtualMachine, + ) -> PyResult { + use ocs_plugin_api::host::SolidOperation; + let origin: [f64; 3] = origin.try_into().map_err(|_| { + vm.new_value_error("ocs.embed_picture: the origin needs 3 numbers".to_owned()) + })?; + let result = host_ctx::with_host(|host| { + host.solid_operation(SolidOperation::EmbedPicture { path, origin, width, layer }) + }) + .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.embed_picture: {error}"))) + } + /// Combine two solids ("union", "subtract" or "intersect"). #[pyfunction] fn solid_boolean( diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index dd3d531db..e6daa7ef4 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -705,6 +705,30 @@ impl<'a> HostSession<'a> { }; self.commit_profile_result(result, source, delete_source, "Extrude profile") } + SolidOperation::EmbedPicture { path, origin, width, layer } => { + finite3("picture origin", origin)?; + positive("picture width", width)?; + if layer.as_ref().is_some_and(|name| name.trim().is_empty()) { + return Err("layer name is empty".into()); + } + let picture = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path)) + .map_err(|error| format!("cannot read the picture {path:?}: {error}"))?; + let (upper_left, lower_right) = crate::io::ole_embed::corners_from_placement( + acadrust::types::Vector3::new(origin[0], origin[1], origin[2]), + width, + picture.aspect(), + ); + let mut frame = crate::io::ole_embed::build_embedded_ole(&picture, upper_left, lower_right); + if let Some(layer) = layer { + frame.common.layer = layer; + } + self.push_undo("Embed picture"); + let handle = self.add_entity(EntityType::Ole2Frame(frame)); + if handle.is_null() { + return Err("the picture frame could not be added".into()); + } + Ok(handle) + } SolidOperation::Boolean { first, second, operation, layer, keep_operands } => { use ocs_plugin_api::host::SolidBoolean; if first == second { @@ -4779,8 +4803,11 @@ mod tests { #[test] fn audit_python_ole2frame_lifecycle_over_real_ipc() { + image::RgbaImage::from_pixel(4, 3, image::Rgba([10, 120, 200, 255])) + .save(std::env::temp_dir().join("ocs_ole_audit.png")) + .unwrap(); run_mesh_lifecycle(&MeshCase { - create: "# MAKEOLE\n", + create: "doc.embed_picture('TMPDIR/ocs_ole_audit.png', origin=(10, 10, 0), width=20)\n", edit: concat!( "e = doc.entities[HANDLE]\n", "with doc.transaction('Edit'):\n", @@ -5672,6 +5699,84 @@ mod tests { } } + #[test] + fn audit_python_ole2frame_creation_rules_over_real_ipc() { + let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else { + return; + }; + let dir = std::env::temp_dir(); + let png = dir.join("ocs_ole_rules.png"); + let jpeg = dir.join("ocs_ole_rules.jpg"); + let tiff = dir.join("ocs_ole_rules.tif"); + let text = dir.join("ocs_ole_rules.txt"); + let picture = image::RgbImage::from_pixel(8, 4, image::Rgb([200, 40, 40])); + picture.save(&png).unwrap(); + picture.save(&jpeg).unwrap(); + picture.save(&tiff).unwrap(); + std::fs::write(&text, "not a picture").unwrap(); + 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 dispatch = |host: &mut HostSession<'_>, command: &str| { + assert!(process.dispatch(host, command, &mut |_| {}).expect("Python dispatch")); + }; + let last = |host: &HostSession<'_>| host.app.command_line.history.last().unwrap().text.clone(); + let frames = |host: &HostSession<'_>| host.document().entities().filter(|e| matches!(e, EntityType::Ole2Frame(_))).count(); + let picture_len = |host: &HostSession<'_>, index: usize| { + let mut all: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Ole2Frame(f) => Some(f.clone()), _ => None }).collect(); + all.sort_by_key(|f| f.common.handle.value()); + match acadrust::entities::extract_presentation(&all[index].encoded_payload()) { + Some(acadrust::entities::OlePresentation::Raster(bytes)) => bytes, + other => panic!("no embedded raster: {other:?}"), + } + }; + let embed = |file: &std::path::Path, extra: &str| format!( + "PY_EVAL ocs.active_document.embed_picture({:?}, origin=(1, 2, 3), width=16{extra}).handle", file.display().to_string()); + + // C: PNG and JPEG are stored as read, a TIFF is re-encoded as PNG, and + // the frame keeps the picture's 2:1 aspect and the requested layer. + dispatch(&mut host, &embed(&png, ", layer='PICTURES'")); + dispatch(&mut host, &embed(&jpeg, "")); + dispatch(&mut host, &embed(&tiff, "")); + assert_eq!(frames(&host), 3, "{}", last(&host)); + assert_eq!(picture_len(&host, 0), std::fs::read(&png).unwrap(), "PNG is stored as read"); + assert_eq!(picture_len(&host, 1), std::fs::read(&jpeg).unwrap(), "JPEG is stored as read"); + assert_eq!(&picture_len(&host, 2)[..4], b"\x89PNG", "TIFF is re-encoded as PNG"); + let mut all: Vec<_> = host.document().entities().filter_map(|e| match e { + EntityType::Ole2Frame(f) => Some(f.clone()), _ => None }).collect(); + all.sort_by_key(|f| f.common.handle.value()); + assert_eq!((all[0].common.layer.as_str(), all[1].common.layer.as_str()), ("PICTURES", "0")); + let frame = &all[0]; + assert!((frame.lower_right_corner.x - frame.upper_left_corner.x - 16.0).abs() < 1e-9, "width 16"); + assert!((frame.upper_left_corner.y - frame.lower_right_corner.y - 8.0).abs() < 1e-9, "height 8 keeps the 2:1 aspect"); + assert!((frame.lower_right_corner.y - 2.0).abs() < 1e-9 && (frame.upper_left_corner.x - 1.0).abs() < 1e-9, "origin is the bottom-left corner"); + + // V: refusals change nothing and record no undo step. + let undo = host.app.tabs[0].history.undo_stack.len(); + let missing = dir.join("ocs_ole_rules_missing.png"); + for (command, message) in [ + (embed(&missing, ""), "cannot read the picture"), + (embed(&text, ""), "cannot read the picture"), + (embed(&png, ", layer=''").replace("width=16", "width=16"), "layer name is empty"), + (embed(&png, "").replace("width=16", "width=0"), "must be finite and greater than zero"), + (embed(&png, "").replace("origin=(1, 2, 3)", "origin=(float('nan'), 0, 0)"), "must be finite"), + ("ocs.embed_picture('x.png', [1.0], 5.0, None)".to_string(), "origin needs 3 numbers"), + ("ocs.active_document.create_entity('Ole2Frame', upper_left_corner=(0, 0, 0), lower_right_corner=(1, 1, 0))".to_string(), "doc.embed_picture"), + ] { + dispatch(&mut host, &format!("PY_EVAL {}", command.trim_start_matches("PY_EVAL "))); + assert!(last(&host).contains(message), "{command}: {}", last(&host)); + } + assert_eq!(frames(&host), 3); + assert_eq!(host.app.tabs[0].history.undo_stack.len(), undo, "refusals record no undo step"); + for file in [&png, &jpeg, &tiff, &text] { + let _ = std::fs::remove_file(file); + } + } + #[test] fn staged_python_point_pick_and_cancel_over_real_ipc() { let Some(plugin_path) = std::env::var_os("OCS_TEST_PYTHON_PLUGIN") else {