codimate 0.1.2__tar.gz → 0.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {codimate-0.1.2 → codimate-0.1.3}/Cargo.lock +12 -1
  2. {codimate-0.1.2 → codimate-0.1.3}/PKG-INFO +2 -1
  3. {codimate-0.1.2 → codimate-0.1.3}/README.md +1 -0
  4. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/scene/mod.rs +1 -1
  5. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/scene/primitive.rs +64 -0
  6. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-py/Cargo.toml +1 -1
  7. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-reconcile/Cargo.toml +6 -0
  8. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-reconcile/src/lib.rs +231 -4
  9. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-render/src/command.rs +125 -0
  10. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-render/src/raster.rs +30 -0
  11. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-render/tests/raster.rs +71 -0
  12. {codimate-0.1.2 → codimate-0.1.3}/pyproject.toml +1 -1
  13. {codimate-0.1.2 → codimate-0.1.3}/python/codimate/__init__.py +1 -1
  14. {codimate-0.1.2 → codimate-0.1.3}/python/codimate/scene.py +20 -0
  15. {codimate-0.1.2 → codimate-0.1.3}/Cargo.toml +0 -0
  16. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-animation/Cargo.toml +0 -0
  17. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-animation/src/lib.rs +0 -0
  18. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-animation/tests/animation.rs +0 -0
  19. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-animation/tests/parallel.rs +0 -0
  20. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-animation/tests/playable.rs +0 -0
  21. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-animation/tests/sequence.rs +0 -0
  22. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-animation/tests/stagger.rs +0 -0
  23. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/Cargo.toml +0 -0
  24. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/builder.rs +0 -0
  25. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/easing.rs +0 -0
  26. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/glyph.rs +0 -0
  27. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/lib.rs +0 -0
  28. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/manim_palette.rs +0 -0
  29. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/path.rs +0 -0
  30. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/scene/circle.rs +0 -0
  31. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/scene/connection.rs +0 -0
  32. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/scene/path.rs +0 -0
  33. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/scene/pulse.rs +0 -0
  34. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/scene/rect.rs +0 -0
  35. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/scene/text.rs +0 -0
  36. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/scene/transform.rs +0 -0
  37. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/src/value.rs +0 -0
  38. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/anchors.rs +0 -0
  39. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/animated.rs +0 -0
  40. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/circle.rs +0 -0
  41. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/connection.rs +0 -0
  42. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/easing.rs +0 -0
  43. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/manim_palette.rs +0 -0
  44. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/path.rs +0 -0
  45. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/ports.rs +0 -0
  46. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/pulse.rs +0 -0
  47. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/rect.rs +0 -0
  48. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/scene.rs +0 -0
  49. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/scene_effect_helpers.rs +0 -0
  50. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-core/tests/tween.rs +0 -0
  51. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-export/Cargo.toml +0 -0
  52. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-export/src/lib.rs +0 -0
  53. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-export/tests/export.rs +0 -0
  54. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-export/tests/png.rs +0 -0
  55. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-fonts/Cargo.toml +0 -0
  56. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-fonts/build.rs +0 -0
  57. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-fonts/fonts/DejaVuSansMono.ttf +0 -0
  58. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-fonts/fonts/DroidSansFallbackFull.ttf +0 -0
  59. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-fonts/fonts/NotoSansKhmer-Regular.ttf +0 -0
  60. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-fonts/src/lib.rs +0 -0
  61. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-glyph/Cargo.toml +0 -0
  62. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-glyph/src/lib.rs +0 -0
  63. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-layout/Cargo.toml +0 -0
  64. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-layout/src/lib.rs +0 -0
  65. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-layout/tests/layout.rs +0 -0
  66. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-math/Cargo.toml +0 -0
  67. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-math/src/lib.rs +0 -0
  68. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-py/src/lib.rs +0 -0
  69. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-render/Cargo.toml +0 -0
  70. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-render/src/lib.rs +0 -0
  71. {codimate-0.1.2 → codimate-0.1.3}/crates/codimate-render/tests/render.rs +0 -0
  72. {codimate-0.1.2 → codimate-0.1.3}/python/codimate/explain.py +0 -0
  73. {codimate-0.1.2 → codimate-0.1.3}/python/codimate/layout.py +0 -0
  74. {codimate-0.1.2 → codimate-0.1.3}/python/codimate/trace.py +0 -0
@@ -217,7 +217,7 @@ dependencies = [
217
217
 
218
218
  [[package]]
219
219
  name = "codimate-py"
220
- version = "0.1.2"
220
+ version = "0.1.3"
221
221
  dependencies = [
222
222
  "codimate-animation",
223
223
  "codimate-core",
@@ -236,6 +236,8 @@ dependencies = [
236
236
  "codimate-core",
237
237
  "codimate-math",
238
238
  "codimate-render",
239
+ "jpeg-decoder",
240
+ "png",
239
241
  ]
240
242
 
241
243
  [[package]]
@@ -586,6 +588,15 @@ version = "1.0.18"
586
588
  source = "registry+https://github.com/rust-lang/crates.io-index"
587
589
  checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
588
590
 
591
+ [[package]]
592
+ name = "jpeg-decoder"
593
+ version = "0.3.2"
594
+ source = "registry+https://github.com/rust-lang/crates.io-index"
595
+ checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
596
+ dependencies = [
597
+ "rayon",
598
+ ]
599
+
589
600
  [[package]]
590
601
  name = "jpeg-encoder"
591
602
  version = "0.6.1"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codimate
3
- Version: 0.1.2
3
+ Version: 0.1.3
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Topic :: Multimedia :: Video
@@ -183,6 +183,7 @@ scene.text(name, content, size=, at=)
183
183
  scene.polygon(name, points) # cm.ngon, cm.star
184
184
  scene.curve(name, points, w=) # smooth, through the points
185
185
  scene.svg(name, "logo.svg", size=) # vector art, as real geometry
186
+ scene.image(name, "photo.jpg", size=) # a picture: PNG or JPEG
186
187
  scene.line(name, start=slot_or_point, end=slot_or_point, w=)
187
188
  scene.formula(name, r"\frac{a}{b}", size=, at=) # LaTeX, needs `typst`
188
189
  cm.measure(text, size) -> (w, h) # to size a box around text
@@ -171,6 +171,7 @@ scene.text(name, content, size=, at=)
171
171
  scene.polygon(name, points) # cm.ngon, cm.star
172
172
  scene.curve(name, points, w=) # smooth, through the points
173
173
  scene.svg(name, "logo.svg", size=) # vector art, as real geometry
174
+ scene.image(name, "photo.jpg", size=) # a picture: PNG or JPEG
174
175
  scene.line(name, start=slot_or_point, end=slot_or_point, w=)
175
176
  scene.formula(name, r"\frac{a}{b}", size=, at=) # LaTeX, needs `typst`
176
177
  cm.measure(text, size) -> (w, h) # to size a box around text
@@ -27,7 +27,7 @@ pub use connection::{connection, Connection};
27
27
  pub use path::{path_node, ConcretePath, PathNode};
28
28
  pub use primitive::{
29
29
  primitive_circle, primitive_path, primitive_rect, primitive_text, ConcreteGeometry,
30
- ConcretePrimitive, Geometry, Primitive, Transformable,
30
+ ConcretePrimitive, Geometry, Pixels, Primitive, Transformable,
31
31
  };
32
32
  pub use pulse::{pulse_on, Pulse};
33
33
  pub use rect::{rect, ConcreteRect, Rect};
@@ -34,6 +34,36 @@ pub enum Geometry {
34
34
  font_size: Animated<f32>,
35
35
  align: TextAlign,
36
36
  },
37
+ /// A decoded raster picture, drawn into a box (ADR 0013).
38
+ ///
39
+ /// The pixels are shared rather than owned: a 1,200-frame render resolves
40
+ /// this once per frame, and copying a megabyte each time would be the
41
+ /// whole cost of the feature.
42
+ Image {
43
+ pixels: std::sync::Arc<Pixels>,
44
+ width: Animated<f32>,
45
+ height: Animated<f32>,
46
+ },
47
+ }
48
+
49
+ /// Decoded pixels: premultiplied RGBA, ready to blit.
50
+ ///
51
+ /// Core does no decoding — it would need a codec and an opinion about file
52
+ /// formats, and neither belongs in the layer that owns no I/O. Something
53
+ /// upstream hands these over already decoded.
54
+ #[derive(Clone, PartialEq)]
55
+ pub struct Pixels {
56
+ pub width: u32,
57
+ pub height: u32,
58
+ pub rgba: Vec<u8>,
59
+ }
60
+
61
+ // Dimensions only. The derived version would print several megabytes of
62
+ // channel bytes the first time anyone put a Scene in a debug log.
63
+ impl std::fmt::Debug for Pixels {
64
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65
+ write!(f, "Pixels({}x{})", self.width, self.height)
66
+ }
37
67
  }
38
68
 
39
69
  /// A `Geometry` resolved at a specific `t` — plain shape data, still local-space.
@@ -54,6 +84,11 @@ pub enum ConcreteGeometry {
54
84
  font_size: f32,
55
85
  align: TextAlign,
56
86
  },
87
+ Image {
88
+ pixels: std::sync::Arc<Pixels>,
89
+ width: f32,
90
+ height: f32,
91
+ },
57
92
  }
58
93
 
59
94
  impl Geometry {
@@ -110,6 +145,7 @@ impl Geometry {
110
145
  }
111
146
  }
112
147
  Geometry::Text { .. } => Vec2::new(0.0, 0.0),
148
+ Geometry::Image { .. } => Vec2::new(0.0, 0.0),
113
149
  }
114
150
  }
115
151
 
@@ -134,10 +170,34 @@ impl Geometry {
134
170
  font_size: font_size.resolve(t),
135
171
  align: *align,
136
172
  },
173
+ Geometry::Image {
174
+ pixels,
175
+ width,
176
+ height,
177
+ } => ConcreteGeometry::Image {
178
+ pixels: pixels.clone(),
179
+ width: width.resolve(t),
180
+ height: height.resolve(t),
181
+ },
137
182
  }
138
183
  }
139
184
 
140
185
  pub(crate) fn ease_with(self, curve: super::TimeCurve) -> Self {
186
+ // An image eases by its box; the pixels are the same pixels whatever
187
+ // the time is.
188
+ if let Geometry::Image {
189
+ pixels,
190
+ width,
191
+ height,
192
+ } = self
193
+ {
194
+ let width_curve = curve.clone();
195
+ return Geometry::Image {
196
+ pixels,
197
+ width: Animated::new(move |t| width.resolve(width_curve(t))),
198
+ height: Animated::new(move |t| height.resolve(curve(t))),
199
+ };
200
+ }
141
201
  match self {
142
202
  Geometry::Circle { radius } => Geometry::Circle {
143
203
  radius: Animated::new(move |t| radius.resolve(curve(t))),
@@ -164,6 +224,9 @@ impl Geometry {
164
224
  align,
165
225
  }
166
226
  }
227
+ // Handled above, before the match, because it needs to move its
228
+ // Arc rather than borrow it.
229
+ Geometry::Image { .. } => unreachable!("eased before the match"),
167
230
  }
168
231
  }
169
232
 
@@ -239,6 +302,7 @@ impl Geometry {
239
302
  Geometry::Rect { .. } => "Rect",
240
303
  Geometry::Path { .. } => "Path",
241
304
  Geometry::Text { .. } => "Text",
305
+ Geometry::Image { .. } => "Image",
242
306
  }
243
307
  }
244
308
  }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "codimate-py"
3
- version = "0.1.2"
3
+ version = "0.1.3"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]
@@ -8,3 +8,9 @@ codimate-core = { path = "../codimate-core" }
8
8
  codimate-animation = { path = "../codimate-animation" }
9
9
  codimate-math = { path = "../codimate-math" }
10
10
  codimate-render = { path = "../codimate-render" }
11
+
12
+ # Decoding an imported picture (ADR 0013). `png` is already in the workspace;
13
+ # `jpeg-decoder` is the one dependency added purely for a feature, because
14
+ # photographs are JPEG and `jpeg-encoder` next door only writes them.
15
+ png = "0.18"
16
+ jpeg-decoder = "0.3"
@@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex, OnceLock};
17
17
  use codimate_animation::Playable;
18
18
  use codimate_core::{
19
19
  scene::AnchorKind, scene::Transformable, tween, Animated, Color, ConcreteScene, Geometry,
20
- IntoAnimated, Path, Primitive, Scene, Segment, Style, TextAlign, Vec2,
20
+ IntoAnimated, Path, Pixels, Primitive, Scene, Segment, Style, TextAlign, Vec2,
21
21
  };
22
22
 
23
23
  /// Why a Scene could not be built.
@@ -85,8 +85,8 @@ pub struct Shape {
85
85
 
86
86
  /// Every `kind` Python may send. An unknown kind is a Python `ValueError`,
87
87
  /// never a silently missing shape.
88
- pub const KINDS: [&str; 8] = [
89
- "rect", "circle", "text", "line", "formula", "polygon", "curve", "svg",
88
+ pub const KINDS: [&str; 9] = [
89
+ "rect", "circle", "text", "line", "formula", "polygon", "curve", "svg", "image",
90
90
  ];
91
91
 
92
92
  /// A rectangle with rounded corners, in local space, centred on the anchor.
@@ -285,7 +285,10 @@ impl Shape {
285
285
  // A formula is many glyph outlines and an imported SVG is many
286
286
  // paths, so neither can be one Geometry. `primitives()` expands
287
287
  // them; these arms are never reached.
288
- "formula" | "svg" => Geometry::rect(0.0.into_animated(), 0.0.into_animated()),
288
+ // `primitives()` handles these three and returns before asking
289
+ // for a Geometry: a formula and an SVG are many, and an image can
290
+ // fail to decode, which this signature cannot report.
291
+ "formula" | "svg" | "image" => Geometry::rect(0.0.into_animated(), 0.0.into_animated()),
289
292
 
290
293
  // Square corners stay a real Rect — the common case keeps the
291
294
  // cheaper primitive and the renderer's own rectangle path.
@@ -754,6 +757,110 @@ pub fn formula_glyphs(latex: &str) -> Result<Arc<Vec<Path>>> {
754
757
  Ok(shared)
755
758
  }
756
759
 
760
+ /// Decode, premultiply and cache a picture — once per path, for the life of
761
+ /// the process.
762
+ ///
763
+ /// Cached for the reason the formula glyphs and SVG artwork are: a 1,200-frame
764
+ /// render must not decode the same megabyte 1,200 times. As with those, it
765
+ /// also means editing the file mid-session and re-rendering shows the old
766
+ /// picture.
767
+ fn image_pixels(file: &str) -> Result<Arc<Pixels>> {
768
+ static CACHE: OnceLock<Mutex<HashMap<String, Arc<Pixels>>>> = OnceLock::new();
769
+ let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
770
+
771
+ if let Some(hit) = cache.lock().unwrap().get(file) {
772
+ return Ok(hit.clone());
773
+ }
774
+
775
+ let bytes = std::fs::read(file)
776
+ .map_err(|e| Error(format!("could not read the image {file:?}: {e}")))?;
777
+
778
+ // By content, not by extension: a `.png` that is really a JPEG is a
779
+ // mistake worth surviving, and the magic numbers are unambiguous.
780
+ let decoded = if bytes.starts_with(&[0x89, b'P', b'N', b'G']) {
781
+ decode_png(&bytes)
782
+ } else if bytes.starts_with(&[0xFF, 0xD8]) {
783
+ decode_jpeg(&bytes)
784
+ } else {
785
+ return Err(Error(format!(
786
+ "{file:?} is not a PNG or a JPEG — those are the two Codimate reads"
787
+ )));
788
+ }
789
+ .map_err(|m| Error(format!("could not read the image {file:?}: {m}")))?;
790
+
791
+ let shared = Arc::new(decoded);
792
+ cache
793
+ .lock()
794
+ .unwrap()
795
+ .insert(file.to_string(), shared.clone());
796
+ Ok(shared)
797
+ }
798
+
799
+ /// Straight RGBA to premultiplied, which is what tiny-skia blits.
800
+ fn premultiplied(width: u32, height: u32, rgba: Vec<u8>) -> Pixels {
801
+ let mut rgba = rgba;
802
+ for px in rgba.chunks_exact_mut(4) {
803
+ let a = px[3] as u32;
804
+ if a == 255 {
805
+ continue;
806
+ }
807
+ for c in 0..3 {
808
+ px[c] = ((px[c] as u32 * a + 127) / 255) as u8;
809
+ }
810
+ }
811
+ Pixels {
812
+ width,
813
+ height,
814
+ rgba,
815
+ }
816
+ }
817
+
818
+ fn decode_png(bytes: &[u8]) -> std::result::Result<Pixels, String> {
819
+ let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
820
+ // Normalise greyscale, palette and 16-bit down to 8-bit colour, and expand
821
+ // transparency into a real alpha channel, so the rest of this sees one
822
+ // layout instead of six.
823
+ decoder.set_transformations(
824
+ png::Transformations::normalize_to_color8() | png::Transformations::ALPHA,
825
+ );
826
+ let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
827
+ let mut buffer = vec![0; reader.output_buffer_size().unwrap_or(0)];
828
+ let info = reader.next_frame(&mut buffer).map_err(|e| e.to_string())?;
829
+ buffer.truncate(info.buffer_size());
830
+
831
+ let rgba = match info.color_type {
832
+ png::ColorType::Rgba => buffer,
833
+ png::ColorType::GrayscaleAlpha => buffer
834
+ .chunks_exact(2)
835
+ .flat_map(|p| [p[0], p[0], p[0], p[1]])
836
+ .collect(),
837
+ other => return Err(format!("unsupported PNG colour type {other:?}")),
838
+ };
839
+ Ok(premultiplied(info.width, info.height, rgba))
840
+ }
841
+
842
+ fn decode_jpeg(bytes: &[u8]) -> std::result::Result<Pixels, String> {
843
+ let mut decoder = jpeg_decoder::Decoder::new(std::io::Cursor::new(bytes));
844
+ let pixels = decoder.decode().map_err(|e| e.to_string())?;
845
+ let info = decoder.info().ok_or("no JPEG header")?;
846
+
847
+ // JPEG has no alpha, so every pixel is opaque and premultiplying is a
848
+ // no-op — the conversion is only about layout.
849
+ let rgba: Vec<u8> = match info.pixel_format {
850
+ jpeg_decoder::PixelFormat::RGB24 => pixels
851
+ .chunks_exact(3)
852
+ .flat_map(|p| [p[0], p[1], p[2], 255])
853
+ .collect(),
854
+ jpeg_decoder::PixelFormat::L8 => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(),
855
+ other => return Err(format!("unsupported JPEG pixel format {other:?}")),
856
+ };
857
+ Ok(Pixels {
858
+ width: info.width as u32,
859
+ height: info.height as u32,
860
+ rgba,
861
+ })
862
+ }
863
+
757
864
  /// An imported SVG, ready to draw.
758
865
  ///
759
866
  /// Paths are centred on the artwork's own bounding box and scaled so its
@@ -800,6 +907,28 @@ fn fit_scale(art: (f32, f32), fit: (f32, f32)) -> f32 {
800
907
  by_w.min(by_h)
801
908
  }
802
909
 
910
+ /// The size a picture is actually drawn at, fitted inside the box the author
911
+ /// named with its aspect kept — the same rule `scene.svg` follows, so the two
912
+ /// imports size the same way.
913
+ fn fitted_box(pixels: &Pixels, fit: (f32, f32)) -> (f32, f32) {
914
+ let natural = (pixels.width.max(1) as f32, pixels.height.max(1) as f32);
915
+ if fit.0 <= 0.0 && fit.1 <= 0.0 {
916
+ return natural;
917
+ }
918
+ let by_w = if fit.0 > 0.0 {
919
+ fit.0 / natural.0
920
+ } else {
921
+ f32::MAX
922
+ };
923
+ let by_h = if fit.1 > 0.0 {
924
+ fit.1 / natural.1
925
+ } else {
926
+ f32::MAX
927
+ };
928
+ let scale = by_w.min(by_h);
929
+ (natural.0 * scale, natural.1 * scale)
930
+ }
931
+
803
932
  /// Read, parse and normalise an SVG file — once per path, for the life of the
804
933
  /// process.
805
934
  ///
@@ -1047,6 +1176,26 @@ fn primitives(before: &Shape, after: &Shape, rules: &[Rule]) -> Result<Vec<Primi
1047
1176
  ));
1048
1177
  }
1049
1178
 
1179
+ // One Geometry, not an expansion — an image is a single thing. It is here
1180
+ // rather than in `geometry()` only because decoding can fail, and that
1181
+ // signature has no way to say so.
1182
+ if before.kind == "image" {
1183
+ let pixels = image_pixels(&before.text)?;
1184
+ let from = fitted_box(&pixels, (before.w, before.h));
1185
+ let to = fitted_box(&pixels, (after.w, after.h));
1186
+ return Ok(vec![Primitive::new(Geometry::Image {
1187
+ pixels,
1188
+ width: tween(from.0, to.0),
1189
+ height: tween(from.1, to.1),
1190
+ })
1191
+ .pos(path)
1192
+ .scale_xy(scale)
1193
+ .rotate(spin)
1194
+ .pivot(pivot)
1195
+ .style(style)
1196
+ .opacity(opacity)]);
1197
+ }
1198
+
1050
1199
  if before.kind == "svg" {
1051
1200
  // Same shape as a formula: one Item, many outlines, revealed left to
1052
1201
  // right. The difference is that the artwork brought its own colours
@@ -1234,6 +1383,9 @@ fn bounds(shape: &Shape) -> (f32, f32, f32, f32) {
1234
1383
  // The fitted box, not the box asked for: a wide drawing in a square
1235
1384
  // `size` does not fill the square, and framing the square would leave
1236
1385
  // the camera looking at empty air above and below.
1386
+ "image" => image_pixels(&shape.text)
1387
+ .map(|p| fitted_box(&p, (shape.w, shape.h)))
1388
+ .unwrap_or((0.0, 0.0)),
1237
1389
  "svg" => svg_art(&shape.text)
1238
1390
  .map(|art| {
1239
1391
  let s = fit_scale(art.size, (shape.w, shape.h));
@@ -1701,6 +1853,81 @@ mod tests {
1701
1853
  assert_ne!(start, mid, "a swap must not snap at t=0");
1702
1854
  }
1703
1855
 
1856
+ /// An SVG on disk, because `svg_art` reads a path rather than a string —
1857
+ /// the payload carries a file name, so the cache can key on it.
1858
+ fn svg_on_disk(name: &str, body: &str) -> String {
1859
+ let path = std::env::temp_dir().join(format!("codimate-test-{name}.svg"));
1860
+ std::fs::write(&path, body).expect("temp file");
1861
+ path.to_string_lossy().into_owned()
1862
+ }
1863
+
1864
+ fn svg_shape(file: &str, colour: &str, fit: (f32, f32)) -> Shape {
1865
+ Shape {
1866
+ item: "art".into(),
1867
+ kind: "svg".into(),
1868
+ x: 0.0,
1869
+ y: 0.0,
1870
+ w: fit.0,
1871
+ h: fit.1,
1872
+ text: file.into(),
1873
+ color: colour.into(),
1874
+ r: 1.0,
1875
+ opacity: 1.0,
1876
+ ..Default::default()
1877
+ }
1878
+ }
1879
+
1880
+ const TWO_SHAPES: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
1881
+ <rect x="0" y="0" width="40" height="40" fill="#ff0000"/>
1882
+ <rect x="60" y="0" width="40" height="40" fill="#0000ff"/>
1883
+ </svg>"##;
1884
+
1885
+ /// The sentinel that makes an import look like the file it came from
1886
+ /// (ADR 0014). An empty `color` means "as authored"; anything else
1887
+ /// flattens the drawing. If `style()` ever stopped honouring the empty
1888
+ /// string, every import would silently go white.
1889
+ #[test]
1890
+ fn an_empty_colour_keeps_the_artwork_own_fills() {
1891
+ let file = svg_on_disk("authored", TWO_SHAPES);
1892
+
1893
+ let as_drawn = svg_shape(&file, "", (100.0, 100.0));
1894
+ let parts = primitives(&as_drawn, &as_drawn, &[]).expect("should expand");
1895
+ assert_eq!(parts.len(), 2, "one primitive per path in the file");
1896
+
1897
+ let fills: Vec<Color> = parts.iter().map(|p| p.resolve(0.0).style.fill).collect();
1898
+ assert!(
1899
+ fills.iter().any(|c| c.r > 0.9 && c.b < 0.1)
1900
+ && fills.iter().any(|c| c.b > 0.9 && c.r < 0.1),
1901
+ "the red and the blue should both survive, got {fills:?}"
1902
+ );
1903
+
1904
+ // And naming a colour overrides every path at once.
1905
+ let flattened = svg_shape(&file, "white", (100.0, 100.0));
1906
+ let parts = primitives(&flattened, &flattened, &[]).expect("should expand");
1907
+ for part in &parts {
1908
+ let fill = part.resolve(0.0).style.fill;
1909
+ assert!(fill.r > 0.9 && fill.g > 0.9 && fill.b > 0.9, "{fill:?}");
1910
+ }
1911
+ }
1912
+
1913
+ /// `size` is a box the drawing fits inside with its aspect kept, so a wide
1914
+ /// file in a square box does not fill the square — and `focus()` frames
1915
+ /// what is actually drawn rather than the box that was asked for.
1916
+ #[test]
1917
+ fn an_import_fits_its_box_without_distorting() {
1918
+ let file = svg_on_disk("fit", TWO_SHAPES);
1919
+ let wide = svg_shape(&file, "", (200.0, 200.0));
1920
+
1921
+ let (x0, y0, x1, y1) = bounds(&wide);
1922
+ let (w, h) = (x1 - x0, y1 - y0);
1923
+ assert!(
1924
+ (w - 200.0).abs() < 0.5,
1925
+ "the long side fills the box, got {w}"
1926
+ );
1927
+ // The artwork is 100x40 of drawn content, so the short side must not.
1928
+ assert!(h < w * 0.6, "aspect kept: {w} by {h}");
1929
+ }
1930
+
1704
1931
  fn curve_of(points: &[(f32, f32)], closed: bool) -> Shape {
1705
1932
  let flat: Vec<f32> = points.iter().flat_map(|(x, y)| [*x, *y]).collect();
1706
1933
  let xs: Vec<f32> = points.iter().map(|p| p.0).collect();
@@ -28,6 +28,18 @@ pub enum RenderCommand {
28
28
  height: f32,
29
29
  fill: Color,
30
30
  },
31
+ /// A decoded picture and the affine that places it.
32
+ ///
33
+ /// Every other command is geometry that can be transformed point by point.
34
+ /// A picture cannot, so the transform travels with it, as
35
+ /// `[sx, ky, kx, sy, tx, ty]` mapping the image's own pixel space to the
36
+ /// frame. `opacity` rides here rather than in a fill, because an image has
37
+ /// no fill colour to carry it.
38
+ Image {
39
+ pixels: std::sync::Arc<codimate_core::Pixels>,
40
+ transform: [f32; 6],
41
+ opacity: f32,
42
+ },
31
43
  Path {
32
44
  segments: Vec<Segment>,
33
45
  closed: bool,
@@ -127,6 +139,42 @@ fn render_primitive_commands(primitive: &ConcretePrimitive) -> Vec<RenderCommand
127
139
  stroke_color: stroke,
128
140
  }]
129
141
  }
142
+ ConcreteGeometry::Image {
143
+ pixels,
144
+ width,
145
+ height,
146
+ } => {
147
+ // The same mapping `transform_point` performs, written as an
148
+ // affine because a picture is blitted rather than walked. Doing it
149
+ // by corner points would place the box but lose the rotation,
150
+ // which is what an earlier version of this did.
151
+ let t = &primitive.transform;
152
+ let (sin, cos) = t.rotation_deg.to_radians().sin_cos();
153
+ let (a, b) = (t.scale.x * cos, t.scale.x * sin);
154
+ let (c, d) = (-t.scale.y * sin, t.scale.y * cos);
155
+ let e = t.pos.x + t.pivot.x - t.pivot.x * a - t.pivot.y * c;
156
+ let f = t.pos.y + t.pivot.y - t.pivot.x * b - t.pivot.y * d;
157
+
158
+ // Composed with the map from the image's pixels to its box, which
159
+ // is centred on the primitive's origin like every other geometry.
160
+ let (w, h) = (*width, *height);
161
+ let (px, py) = (
162
+ w / pixels.width.max(1) as f32,
163
+ h / pixels.height.max(1) as f32,
164
+ );
165
+ vec![RenderCommand::Image {
166
+ pixels: pixels.clone(),
167
+ transform: [
168
+ a * px,
169
+ b * px,
170
+ c * py,
171
+ d * py,
172
+ e - a * w / 2.0 - c * h / 2.0,
173
+ f - b * w / 2.0 - d * h / 2.0,
174
+ ],
175
+ opacity: t.opacity,
176
+ }]
177
+ }
130
178
  ConcreteGeometry::Text {
131
179
  text,
132
180
  font_size,
@@ -245,3 +293,80 @@ pub trait Renderer {
245
293
 
246
294
  fn render(&mut self, frame: &LayoutFrame) -> Result<(), Self::Error>;
247
295
  }
296
+
297
+ #[cfg(test)]
298
+ mod image_tests {
299
+ use super::*;
300
+ use codimate_core::{ConcretePrimitive, ConcreteTransform, Pixels, Style, Vec2};
301
+ use std::sync::Arc;
302
+
303
+ fn one_red_pixel() -> Arc<Pixels> {
304
+ Arc::new(Pixels {
305
+ width: 1,
306
+ height: 1,
307
+ rgba: vec![255, 0, 0, 255],
308
+ })
309
+ }
310
+
311
+ fn placed(rotation_deg: f32, scale: Vec2) -> ConcretePrimitive {
312
+ ConcretePrimitive {
313
+ transform: ConcreteTransform {
314
+ pos: Vec2::new(100.0, 50.0),
315
+ scale,
316
+ rotation_deg,
317
+ pivot: Vec2::new(0.0, 0.0),
318
+ opacity: 1.0,
319
+ },
320
+ style: Style::new(),
321
+ geometry: ConcreteGeometry::Image {
322
+ pixels: one_red_pixel(),
323
+ width: 20.0,
324
+ height: 10.0,
325
+ },
326
+ }
327
+ }
328
+
329
+ fn affine_of(primitive: &ConcretePrimitive) -> [f32; 6] {
330
+ match render_primitive_commands(primitive).remove(0) {
331
+ RenderCommand::Image { transform, .. } => transform,
332
+ other => panic!("expected an Image command, got {other:?}"),
333
+ }
334
+ }
335
+
336
+ /// The regression this exists for. Every other command is geometry and is
337
+ /// transformed point by point; the first version of the image command
338
+ /// placed the picture by transforming its centre and emitting an
339
+ /// axis-aligned box, which put it in exactly the right place and silently
340
+ /// threw the rotation away. Nothing caught it but looking at a picture.
341
+ #[test]
342
+ fn a_turned_image_carries_its_rotation() {
343
+ let [_, ky, kx, _, _, _] = affine_of(&placed(25.0, Vec2::new(1.0, 1.0)));
344
+ assert!(
345
+ ky.abs() > 0.01 && kx.abs() > 0.01,
346
+ "a rotated image must have skew terms, got kx={kx} ky={ky}"
347
+ );
348
+
349
+ let [_, ky, kx, _, _, _] = affine_of(&placed(0.0, Vec2::new(1.0, 1.0)));
350
+ assert!(
351
+ ky.abs() < 1e-6 && kx.abs() < 1e-6,
352
+ "an unrotated image must not, got kx={kx} ky={ky}"
353
+ );
354
+ }
355
+
356
+ /// The affine maps the image's own pixel space, so a one-pixel source
357
+ /// drawn into a 20x10 box scales by exactly that.
358
+ #[test]
359
+ fn the_affine_maps_pixels_to_the_box() {
360
+ let [sx, _, _, sy, tx, ty] = affine_of(&placed(0.0, Vec2::new(1.0, 1.0)));
361
+ assert_eq!((sx, sy), (20.0, 10.0), "one source pixel fills the box");
362
+ // Centred on the primitive's position, like every other geometry.
363
+ assert_eq!((tx, ty), (100.0 - 10.0, 50.0 - 5.0));
364
+ }
365
+
366
+ /// `.grow()` reaches an image, unlike `.turn()` before this was fixed.
367
+ #[test]
368
+ fn scale_reaches_an_image() {
369
+ let [sx, _, _, sy, _, _] = affine_of(&placed(0.0, Vec2::new(2.0, 0.5)));
370
+ assert_eq!((sx, sy), (40.0, 5.0));
371
+ }
372
+ }
@@ -88,6 +88,36 @@ fn rasterize_commands(viewport: Viewport, commands: &[RenderCommand], pixel_scal
88
88
  pixmap.fill_path(&path, &paint, FillRule::Winding, transform, None);
89
89
  }
90
90
  }
91
+ RenderCommand::Image {
92
+ pixels,
93
+ transform: affine,
94
+ opacity,
95
+ } => {
96
+ if let Some(view) = tiny_skia::PixmapRef::from_bytes(
97
+ &pixels.rgba,
98
+ pixels.width.max(1),
99
+ pixels.height.max(1),
100
+ ) {
101
+ let placed = transform.pre_concat(tiny_skia::Transform::from_row(
102
+ affine[0], affine[1], affine[2], affine[3], affine[4], affine[5],
103
+ ));
104
+ pixmap.draw_pixmap(
105
+ 0,
106
+ 0,
107
+ view,
108
+ &tiny_skia::PixmapPaint {
109
+ opacity: opacity.clamp(0.0, 1.0),
110
+ // Bilinear: an image is nearly always drawn at some
111
+ // size other than its own, and nearest neighbour on
112
+ // a photograph is visibly wrong.
113
+ quality: tiny_skia::FilterQuality::Bilinear,
114
+ ..Default::default()
115
+ },
116
+ placed,
117
+ None,
118
+ );
119
+ }
120
+ }
91
121
  RenderCommand::Rect {
92
122
  x,
93
123
  y,
@@ -4,6 +4,7 @@ use codimate_core::{circle_path, Color, Segment, TextAlign, Vec2};
4
4
  use codimate_fonts::FontRegistry;
5
5
  use codimate_layout::Viewport;
6
6
  use codimate_render::{rasterize, RenderCommand, RenderFrame};
7
+ use std::sync::Arc;
7
8
 
8
9
  fn red_circle_frame() -> RenderFrame {
9
10
  RenderFrame {
@@ -334,3 +335,73 @@ fn rasterize_path_cubic_circle() {
334
335
  // Far corner is black (background)
335
336
  assert_eq!(img.pixel(0, 0), (0, 0, 0, 255));
336
337
  }
338
+
339
+ /// A picture actually reaching the pixels — the end of the path that ADR 0013
340
+ /// added and that nothing else here covers.
341
+ fn image_frame(transform: [f32; 6], opacity: f32) -> RenderFrame {
342
+ // One solid red pixel, stretched by the affine into whatever box it names.
343
+ let pixels = Arc::new(codimate_core::Pixels {
344
+ width: 1,
345
+ height: 1,
346
+ rgba: vec![255, 0, 0, 255],
347
+ });
348
+ RenderFrame {
349
+ name: "image-test".to_string(),
350
+ elapsed_seconds: 0.0,
351
+ viewport: Viewport::new(100.0, 100.0),
352
+ commands: vec![RenderCommand::Image {
353
+ pixels,
354
+ transform,
355
+ opacity,
356
+ }],
357
+ }
358
+ }
359
+
360
+ #[test]
361
+ fn rasterize_image_fills_the_box_it_is_given() {
362
+ // Twenty by twenty, placed at (40, 40).
363
+ let img = rasterize(&image_frame([20.0, 0.0, 0.0, 20.0, 40.0, 40.0], 1.0));
364
+
365
+ assert_eq!(img.pixel(50, 50), (255, 0, 0, 255), "inside the box");
366
+ assert_eq!(img.pixel(5, 5), (0, 0, 0, 255), "outside it");
367
+ assert_eq!(img.pixel(90, 90), (0, 0, 0, 255), "and past the far edge");
368
+ }
369
+
370
+ /// The regression the command-level test guards from the other side: a turned
371
+ /// image must actually land turned. A square drawn axis-aligned has black
372
+ /// corners inside its bounding box only when it is rotated.
373
+ #[test]
374
+ fn rasterize_image_honours_rotation() {
375
+ let (sin, cos) = 45f32.to_radians().sin_cos();
376
+ let (w, h) = (30.0, 30.0);
377
+ // The same composition `render_primitive_commands` builds: rotate, then
378
+ // offset so the box is centred on (50, 50).
379
+ let (a, b, c, d) = (cos * w, sin * w, -sin * h, cos * h);
380
+ let turned = rasterize(&image_frame(
381
+ [a, b, c, d, 50.0 - (a + c) / 2.0, 50.0 - (b + d) / 2.0],
382
+ 1.0,
383
+ ));
384
+ let square = rasterize(&image_frame([w, 0.0, 0.0, h, 35.0, 35.0], 1.0));
385
+
386
+ // Centre is red either way; the corner of the upright square is red and
387
+ // the corner of the diamond is not, because it rotated away.
388
+ assert_eq!(turned.pixel(50, 50), (255, 0, 0, 255));
389
+ assert_eq!(square.pixel(50, 50), (255, 0, 0, 255));
390
+ assert_eq!(
391
+ square.pixel(37, 37),
392
+ (255, 0, 0, 255),
393
+ "upright fills its corner"
394
+ );
395
+ assert_eq!(turned.pixel(37, 37), (0, 0, 0, 255), "turned does not");
396
+ }
397
+
398
+ #[test]
399
+ fn rasterize_image_honours_opacity() {
400
+ let half = rasterize(&image_frame([20.0, 0.0, 0.0, 20.0, 40.0, 40.0], 0.5));
401
+ let (r, g, b, a) = half.pixel(50, 50);
402
+ assert!(
403
+ (100..=160).contains(&r) && g == 0 && b == 0 && a == 255,
404
+ "half-opaque red over black should be mid red, got {:?}",
405
+ (r, g, b, a)
406
+ );
407
+ }
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "codimate"
7
- version = "0.1.2"
7
+ version = "0.1.3"
8
8
  description = "Turn a running algorithm into an explainer video"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -21,7 +21,7 @@ encoding — happens in Rust (ADR 0008).
21
21
  ## What a moment looks like
22
22
 
23
23
  - `Scene` — one picture: `rect`, `circle`, `polygon`, `curve`, `arrow`,
24
- `text`, `line`, `formula`, `svg`
24
+ `text`, `line`, `formula`, `svg`, `image`
25
25
  - `ngon`, `star` — corners for a polygon, so you do not compute them
26
26
  - `Group` — several shapes that move together
27
27
  - `Handle` — what a shape call returns: `.fill()`, `.round()`, `.turn()`,
@@ -290,6 +290,26 @@ class Group:
290
290
  return self._place(key, "formula", x=x, y=y, text=latex, size=size,
291
291
  layer=10, r=1.0)
292
292
 
293
+ def image(self, key: Hashable, file, *, size=None, at=None) -> "Handle":
294
+ """A picture — a photo, a screenshot, a figure — drawn into the frame.
295
+
296
+ scene.image("paper", "figures/attention.png", size=(520, 300))
297
+ scene.image("shot", "screen.png", size=400).on(opacity=0.4)
298
+
299
+ PNG and JPEG, read by content rather than by extension. `size` is a box
300
+ it fits inside, one number or `(w, h)`, aspect always kept; leave it out
301
+ and the picture is drawn at its own pixel size.
302
+
303
+ It moves, scales, turns and fades like anything else, and `focus()`
304
+ frames it. For a logo or a diagram prefer `svg`, which arrives as
305
+ geometry you can recolour and draw on — an image is pixels, so it can
306
+ only be placed.
307
+ """
308
+ box = (size, size) if isinstance(size, (int, float)) else size
309
+ w, h = (0.0, 0.0) if box is None else (float(box[0]), float(box[1]))
310
+ x, y = self._where(at, 0.0)
311
+ return self._place(key, "image", x=x, y=y, text=str(file), w=w, h=h)
312
+
293
313
  def svg(self, key: Hashable, file, *, size=120.0, at=None) -> "Handle":
294
314
  """Vector art from a file, drawn as real geometry.
295
315
 
File without changes