codimate 0.1.1__tar.gz → 0.1.2__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 (75) hide show
  1. {codimate-0.1.1 → codimate-0.1.2}/Cargo.lock +2 -1
  2. {codimate-0.1.1 → codimate-0.1.2}/PKG-INFO +2 -1
  3. {codimate-0.1.1 → codimate-0.1.2}/README.md +1 -0
  4. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-export/src/lib.rs +8 -0
  5. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-math/Cargo.toml +5 -0
  6. codimate-0.1.2/crates/codimate-math/src/lib.rs +565 -0
  7. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-py/Cargo.toml +1 -1
  8. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-reconcile/src/lib.rs +352 -73
  9. {codimate-0.1.1 → codimate-0.1.2}/pyproject.toml +1 -1
  10. {codimate-0.1.1 → codimate-0.1.2}/python/codimate/__init__.py +1 -1
  11. {codimate-0.1.1 → codimate-0.1.2}/python/codimate/scene.py +50 -2
  12. codimate-0.1.1/crates/codimate-math/src/lib.rs +0 -293
  13. {codimate-0.1.1 → codimate-0.1.2}/Cargo.toml +0 -0
  14. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/Cargo.toml +0 -0
  15. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/src/lib.rs +0 -0
  16. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/animation.rs +0 -0
  17. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/parallel.rs +0 -0
  18. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/playable.rs +0 -0
  19. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/sequence.rs +0 -0
  20. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/stagger.rs +0 -0
  21. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/Cargo.toml +0 -0
  22. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/builder.rs +0 -0
  23. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/easing.rs +0 -0
  24. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/glyph.rs +0 -0
  25. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/lib.rs +0 -0
  26. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/manim_palette.rs +0 -0
  27. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/path.rs +0 -0
  28. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/circle.rs +0 -0
  29. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/connection.rs +0 -0
  30. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/mod.rs +0 -0
  31. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/path.rs +0 -0
  32. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/primitive.rs +0 -0
  33. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/pulse.rs +0 -0
  34. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/rect.rs +0 -0
  35. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/text.rs +0 -0
  36. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/transform.rs +0 -0
  37. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/value.rs +0 -0
  38. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/anchors.rs +0 -0
  39. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/animated.rs +0 -0
  40. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/circle.rs +0 -0
  41. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/connection.rs +0 -0
  42. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/easing.rs +0 -0
  43. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/manim_palette.rs +0 -0
  44. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/path.rs +0 -0
  45. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/ports.rs +0 -0
  46. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/pulse.rs +0 -0
  47. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/rect.rs +0 -0
  48. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/scene.rs +0 -0
  49. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/scene_effect_helpers.rs +0 -0
  50. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/tween.rs +0 -0
  51. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-export/Cargo.toml +0 -0
  52. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-export/tests/export.rs +0 -0
  53. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-export/tests/png.rs +0 -0
  54. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/Cargo.toml +0 -0
  55. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/build.rs +0 -0
  56. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/fonts/DejaVuSansMono.ttf +0 -0
  57. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/fonts/DroidSansFallbackFull.ttf +0 -0
  58. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/fonts/NotoSansKhmer-Regular.ttf +0 -0
  59. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/src/lib.rs +0 -0
  60. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-glyph/Cargo.toml +0 -0
  61. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-glyph/src/lib.rs +0 -0
  62. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-layout/Cargo.toml +0 -0
  63. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-layout/src/lib.rs +0 -0
  64. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-layout/tests/layout.rs +0 -0
  65. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-py/src/lib.rs +0 -0
  66. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-reconcile/Cargo.toml +0 -0
  67. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/Cargo.toml +0 -0
  68. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/src/command.rs +0 -0
  69. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/src/lib.rs +0 -0
  70. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/src/raster.rs +0 -0
  71. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/tests/raster.rs +0 -0
  72. {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/tests/render.rs +0 -0
  73. {codimate-0.1.1 → codimate-0.1.2}/python/codimate/explain.py +0 -0
  74. {codimate-0.1.1 → codimate-0.1.2}/python/codimate/layout.py +0 -0
  75. {codimate-0.1.1 → codimate-0.1.2}/python/codimate/trace.py +0 -0
@@ -211,12 +211,13 @@ version = "0.1.0"
211
211
  dependencies = [
212
212
  "codimate-core",
213
213
  "mitex",
214
+ "roxmltree",
214
215
  "usvg",
215
216
  ]
216
217
 
217
218
  [[package]]
218
219
  name = "codimate-py"
219
- version = "0.1.1"
220
+ version = "0.1.2"
220
221
  dependencies = [
221
222
  "codimate-animation",
222
223
  "codimate-core",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codimate
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  Classifier: Topic :: Multimedia :: Video
@@ -182,6 +182,7 @@ scene.circle(name, r=, at=)
182
182
  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
+ scene.svg(name, "logo.svg", size=) # vector art, as real geometry
185
186
  scene.line(name, start=slot_or_point, end=slot_or_point, w=)
186
187
  scene.formula(name, r"\frac{a}{b}", size=, at=) # LaTeX, needs `typst`
187
188
  cm.measure(text, size) -> (w, h) # to size a box around text
@@ -170,6 +170,7 @@ scene.circle(name, r=, at=)
170
170
  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
+ scene.svg(name, "logo.svg", size=) # vector art, as real geometry
173
174
  scene.line(name, start=slot_or_point, end=slot_or_point, w=)
174
175
  scene.formula(name, r"\frac{a}{b}", size=, at=) # LaTeX, needs `typst`
175
176
  cm.measure(text, size) -> (w, h) # to size a box around text
@@ -294,6 +294,14 @@ pub fn export_mp4(
294
294
  let mut command = Command::new(encoder_binary());
295
295
  command
296
296
  .arg("-y")
297
+ // ffmpeg's default is 46 lines of banner, build configuration and
298
+ // libx264 internals per render, which buries anything that actually
299
+ // went wrong and is the first thing a new author sees. `error` keeps
300
+ // the failures; `-stats` keeps the one rewriting progress line, so a
301
+ // two-minute render still looks alive rather than hung.
302
+ .arg("-loglevel")
303
+ .arg("error")
304
+ .arg("-stats")
297
305
  .arg("-f")
298
306
  .arg("rawvideo")
299
307
  .arg("-pix_fmt")
@@ -8,3 +8,8 @@ codimate-core = { path = "../codimate-core" }
8
8
  # See docs/adr/0005; pinned for deterministic golden images.
9
9
  mitex = "0.2" # LaTeX -> Typst markup
10
10
  usvg = { version = "0.47", default-features = false } # SVG -> path geometry
11
+ # Already in the tree as a usvg dependency. Declared directly so an imported
12
+ # SVG's `<text>` can be read: usvg is built without its `text` feature and
13
+ # discards those elements while parsing, so they have to be picked up from the
14
+ # source (ADR 0014).
15
+ roxmltree = "0.21"
@@ -0,0 +1,565 @@
1
+ //! `codimate-math` — typeset LaTeX math into `codimate-core` `Path` nodes.
2
+ //!
3
+ //! A [`Formula`] is a group of glyph outlines (animatable Béziers), produced by
4
+ //! shelling out to Typst once at Scene-build time. See `docs/adr/0005`.
5
+ //!
6
+ //! Pipeline:
7
+ //! ```text
8
+ //! LaTeX --mitex--> Typst markup --typst(bin)--> SVG --usvg--> Path nodes
9
+ //! ```
10
+ //!
11
+ //! # The One-Law boundary (load-bearing)
12
+ //! [`formula`] runs the Typst subprocess **once, at Scene-construction time**,
13
+ //! and bakes the result into static `PathNode`s. It is never called inside
14
+ //! `resolve(t)`, so `f(t) -> Scene` stays pure (Invariant 1). Treat it like
15
+ //! loading an asset at build time, not a per-frame side effect.
16
+
17
+ use std::path::PathBuf;
18
+
19
+ use codimate_core::{Color, GlyphBlock, PathNode, Segment, Vec2};
20
+
21
+ /// Why a [`formula()`] could not be produced.
22
+ #[derive(Debug)]
23
+ pub enum FormulaError {
24
+ /// `mitex` could not translate the LaTeX into Typst markup.
25
+ Mitex(String),
26
+ /// The `typst` binary could not be spawned — is it installed / on `PATH`?
27
+ TypstSpawn(std::io::Error),
28
+ /// `typst` ran but reported a compile error.
29
+ TypstCompile(String),
30
+ /// The emitted SVG could not be parsed into paths.
31
+ Svg(String),
32
+ /// An imported SVG file could not be read.
33
+ SvgRead(String),
34
+ /// An imported SVG draws text, which this build cannot render.
35
+ SvgText(String),
36
+ }
37
+
38
+ /// Typeset a LaTeX **math** string (e.g. `r"\frac{Q_{enc}}{\epsilon_0}"`) into a
39
+ /// group of glyph `Path` nodes filled with `fill`.
40
+ ///
41
+ /// Runs at build time only — see the crate-level One-Law note.
42
+ pub fn formula(latex: &str, fill: Color) -> Result<GlyphBlock, FormulaError> {
43
+ let typst_src = latex_to_typst(latex)?;
44
+ let svg = typst_compile(&typst_src)?;
45
+ let glyphs = svg_to_paths(&svg, fill)?;
46
+ Ok(GlyphBlock::from_glyphs(glyphs))
47
+ }
48
+
49
+ // --- pipeline stages ------------------------------------------------------
50
+ // Each stage is a clean seam.
51
+
52
+ /// Stage 1 — LaTeX -> Typst markup via the `mitex` crate.
53
+ fn latex_to_typst(latex: &str) -> Result<String, FormulaError> {
54
+ match mitex::convert_math(latex, None) {
55
+ Ok(typst) => Ok(typst),
56
+ Err(e) => Err(FormulaError::Mitex(e)),
57
+ }
58
+ }
59
+
60
+ /// Helpers `mitex` emits but does not define.
61
+ ///
62
+ /// `mitex` translates some LaTeX into calls on its own Typst package rather
63
+ /// than into plain Typst — `\sqrt{x}` becomes `mitexsqrt(x)`, for instance.
64
+ /// The package would have to be fetched from Typst's registry at compile time,
65
+ /// so the four helpers it actually reaches for are defined here instead. That
66
+ /// keeps the pipeline offline, which is the same reason `typst` is a binary
67
+ /// rather than a crate (ADR 0005).
68
+ ///
69
+ /// Without this, `\sqrt` fails — which is most of the formulas anyone wants.
70
+ const MITEX_PRELUDE: &str = r#"
71
+ #let textmath(body) = text(body)
72
+ #let mitexsqrt(..args) = if args.pos().len() == 1 { math.sqrt(..args) } else { math.root(..args) }
73
+ #let mitexmathbf(x) = math.bold(math.upright(x))
74
+ #let negthinspace = h(-(3/18) * 1em)
75
+ #let bmatrix(..args) = math.mat(delim: "[", ..args)
76
+ #let pmatrix(..args) = math.mat(delim: "(", ..args)
77
+ #let vmatrix(..args) = math.mat(delim: "|", ..args)
78
+ #let Bmatrix(..args) = math.mat(delim: "{", ..args)
79
+ "#;
80
+
81
+ /// Stage 2 — Typst markup -> SVG via the external `typst` binary.
82
+ ///
83
+ /// Wraps the math in a minimal page, writes to a hash-keyed temp file,
84
+ /// shells out to `typst compile --format svg`, and caches the result.
85
+ fn typst_compile(typst_src: &str) -> Result<String, FormulaError> {
86
+ use std::collections::hash_map::DefaultHasher;
87
+ use std::fs;
88
+ use std::hash::{Hash, Hasher};
89
+ use std::io::Write;
90
+ use std::process::Command;
91
+
92
+ let doc = format!(
93
+ "#set page(width: auto, height: auto, margin: 0pt, fill: none)\n\
94
+ {MITEX_PRELUDE}\n\
95
+ $ {typst_src} $\n"
96
+ );
97
+
98
+ let mut hasher = DefaultHasher::new();
99
+ doc.hash(&mut hasher);
100
+ let hash = hasher.finish();
101
+
102
+ let cache = cache_dir();
103
+ let svg_path = cache.join(format!("{hash:x}.svg"));
104
+
105
+ if svg_path.exists() {
106
+ return fs::read_to_string(&svg_path).map_err(|e| FormulaError::Svg(e.to_string()));
107
+ }
108
+
109
+ fs::create_dir_all(&cache).map_err(|e| FormulaError::Svg(e.to_string()))?;
110
+
111
+ let typ_path = cache.join(format!("{hash:x}.typ"));
112
+ {
113
+ let mut f = fs::File::create(&typ_path).map_err(|e| FormulaError::Svg(e.to_string()))?;
114
+ f.write_all(doc.as_bytes())
115
+ .map_err(|e| FormulaError::Svg(e.to_string()))?;
116
+ }
117
+
118
+ let output = Command::new("typst")
119
+ .args(["compile", "--format", "svg"])
120
+ .arg(&typ_path)
121
+ .arg(&svg_path)
122
+ .output()
123
+ .map_err(FormulaError::TypstSpawn)?;
124
+
125
+ if !output.status.success() {
126
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
127
+ return Err(FormulaError::TypstCompile(stderr));
128
+ }
129
+
130
+ fs::read_to_string(&svg_path).map_err(|e| FormulaError::Svg(e.to_string()))
131
+ }
132
+
133
+ fn cache_dir() -> PathBuf {
134
+ std::env::temp_dir().join("codimate-math")
135
+ }
136
+
137
+ /// One path out of an imported SVG, with the colour it was authored in.
138
+ ///
139
+ /// `fill` is `None` when the file gave the path no paint this build can read —
140
+ /// a pattern, say. The caller decides what to do about it.
141
+ pub struct SvgPath {
142
+ pub path: codimate_core::Path,
143
+ pub fill: Option<Color>,
144
+ }
145
+
146
+ /// Read an SVG's geometry, keeping each path's own colour (ADR 0014).
147
+ ///
148
+ /// This is the same stage the formula pipeline ends with — LaTeX becomes SVG
149
+ /// and then paths — with two differences: the source is a file rather than
150
+ /// typst's output, and the artwork's own fills are kept instead of being
151
+ /// replaced by one ink.
152
+ ///
153
+ /// Geometry only. `usvg` is built without its `text` feature and drops
154
+ /// `<text>` while parsing, so labels are read separately by
155
+ /// [`import_svg_text`].
156
+ pub fn import_svg(svg: &str) -> Result<Vec<SvgPath>, FormulaError> {
157
+ let opts = usvg::Options::default();
158
+ let tree = usvg::Tree::from_str(svg, &opts).map_err(|e| FormulaError::Svg(e.to_string()))?;
159
+ let mut out = Vec::new();
160
+ collect_svg(tree.root(), &mut out);
161
+ Ok(out)
162
+ }
163
+
164
+ /// A line of text from an imported SVG, in the file's own coordinates.
165
+ ///
166
+ /// Not shaped here. It becomes a `Geometry::Text` primitive and is shaped at
167
+ /// draw time by the same call that draws every `scene.text` — one text stack
168
+ /// rather than two (ADR 0014).
169
+ pub struct SvgText {
170
+ pub content: String,
171
+ /// Where the anchor sits, after `text-anchor` has been applied.
172
+ pub x: f32,
173
+ pub y: f32,
174
+ pub size: f32,
175
+ pub fill: Option<Color>,
176
+ /// `start`, `middle` or `end`, as written.
177
+ pub anchor: String,
178
+ }
179
+
180
+ /// Read an imported SVG's `<text>` elements.
181
+ ///
182
+ /// A second pass over the source rather than a walk of the parsed tree,
183
+ /// because `usvg` is built without its `text` feature and has already thrown
184
+ /// these away by the time it hands back a tree. Enabling that feature was the
185
+ /// alternative and was rejected: it brings a second font database and a second
186
+ /// shaper, so the same string in the same font would be shaped one way from
187
+ /// `scene.text` and another from an import, inside one frame.
188
+ ///
189
+ /// Deliberately narrow. A `<tspan>`, a `<textPath>`, or text under a
190
+ /// transformed group is refused rather than guessed at — this reads the common
191
+ /// case, which is a label placed at an absolute point.
192
+ pub fn import_svg_text(svg: &str) -> Result<Vec<SvgText>, FormulaError> {
193
+ let doc = roxmltree::Document::parse(svg).map_err(|e| FormulaError::Svg(e.to_string()))?;
194
+
195
+ for node in doc.descendants() {
196
+ if node.has_tag_name("tspan") || node.has_tag_name("textPath") {
197
+ return Err(FormulaError::SvgText(
198
+ "this SVG lays out text in a way Codimate cannot read \
199
+ (tspan or textPath) — export it with text converted to outlines"
200
+ .into(),
201
+ ));
202
+ }
203
+ }
204
+
205
+ let mut out = Vec::new();
206
+ for node in doc.descendants().filter(|n| n.has_tag_name("text")) {
207
+ // Text nodes only. `descendants()` yields the element as well, and
208
+ // `Node::text()` on an element returns its first text child — so
209
+ // collecting from everything counts the content twice.
210
+ let content: String = node
211
+ .descendants()
212
+ .filter(|n| n.is_text())
213
+ .filter_map(|n| n.text())
214
+ .collect();
215
+ if content.trim().is_empty() {
216
+ continue;
217
+ }
218
+
219
+ // A transform on the label, or on anything above it, is nearly always
220
+ // a rotation — and the renderer cannot turn glyphs at all, the same
221
+ // limitation `.turn()` has on `scene.text`. So this is not a shortcut:
222
+ // there is no way to draw the label correctly, and drawing it upright
223
+ // in the wrong place would be a silently wrong picture. Named, so the
224
+ // author knows which label to outline.
225
+ if node.ancestors().any(|a| a.attribute("transform").is_some()) {
226
+ return Err(FormulaError::SvgText(format!(
227
+ "this SVG turns the label {:?}, and Codimate cannot draw \
228
+ turned text — export it with text converted to outlines",
229
+ content.trim()
230
+ )));
231
+ }
232
+
233
+ out.push(SvgText {
234
+ content: content.trim().to_string(),
235
+ x: inherited(&node, "x").unwrap_or(0.0),
236
+ y: inherited(&node, "y").unwrap_or(0.0),
237
+ size: inherited(&node, "font-size").unwrap_or(16.0),
238
+ fill: inherited_str(&node, "fill")
239
+ .as_deref()
240
+ .and_then(parse_css_colour),
241
+ anchor: inherited_str(&node, "text-anchor").unwrap_or_else(|| "start".into()),
242
+ });
243
+ }
244
+ Ok(out)
245
+ }
246
+
247
+ /// An attribute on this element or the nearest ancestor that sets it — SVG
248
+ /// presentation attributes inherit, and `font-size` is usually on the root.
249
+ fn inherited_str(node: &roxmltree::Node, name: &str) -> Option<String> {
250
+ node.ancestors()
251
+ .find_map(|n| n.attribute(name))
252
+ .map(|v| v.trim().to_string())
253
+ }
254
+
255
+ fn inherited(node: &roxmltree::Node, name: &str) -> Option<f32> {
256
+ let raw = inherited_str(node, name)?;
257
+ // `12`, `12px` and `12pt` all appear; anything with other units is left to
258
+ // the default rather than guessed at.
259
+ let digits: String = raw
260
+ .chars()
261
+ .take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-')
262
+ .collect();
263
+ digits.parse().ok()
264
+ }
265
+
266
+ /// `#rgb`, `#rrggbb` or `none`. Anything else — a named colour, a gradient
267
+ /// reference — falls back to the caller's choice rather than being invented.
268
+ fn parse_css_colour(raw: &str) -> Option<Color> {
269
+ let hex = raw.trim().strip_prefix('#')?;
270
+ let (r, g, b) = match hex.len() {
271
+ 3 => {
272
+ let d = |i: usize| u8::from_str_radix(&hex[i..i + 1].repeat(2), 16).ok();
273
+ (d(0)?, d(1)?, d(2)?)
274
+ }
275
+ 6 => {
276
+ let d = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok();
277
+ (d(0)?, d(2)?, d(4)?)
278
+ }
279
+ _ => return None,
280
+ };
281
+ Some(Color {
282
+ r: r as f32 / 255.0,
283
+ g: g as f32 / 255.0,
284
+ b: b as f32 / 255.0,
285
+ a: 1.0,
286
+ })
287
+ }
288
+
289
+ fn collect_svg(group: &usvg::Group, out: &mut Vec<SvgPath>) {
290
+ for node in group.children() {
291
+ match node {
292
+ usvg::Node::Path(path) => {
293
+ if !path.is_visible() {
294
+ continue;
295
+ }
296
+ let segments = extract_segments(path);
297
+ if segments.is_empty() {
298
+ continue;
299
+ }
300
+ out.push(SvgPath {
301
+ path: codimate_core::Path {
302
+ segments,
303
+ closed: false,
304
+ },
305
+ fill: authored_colour(path),
306
+ });
307
+ }
308
+ usvg::Node::Group(g) => collect_svg(g, out),
309
+ _ => {}
310
+ }
311
+ }
312
+ }
313
+
314
+ /// The colour a path was drawn in.
315
+ ///
316
+ /// A stroke-only path reports its stroke, because `extract_segments` turned
317
+ /// that stroke into a fillable outline and the outline should be the colour
318
+ /// the stroke was. A gradient collapses to its first stop: `Style` carries one
319
+ /// flat colour, and one of the real colours beats refusing the file.
320
+ fn authored_colour(path: &usvg::Path) -> Option<Color> {
321
+ fn from_paint(paint: &usvg::Paint, opacity: f32) -> Option<Color> {
322
+ let (r, g, b) = match paint {
323
+ usvg::Paint::Color(c) => (c.red, c.green, c.blue),
324
+ usvg::Paint::LinearGradient(g) => {
325
+ let s = g.stops().first()?.color();
326
+ (s.red, s.green, s.blue)
327
+ }
328
+ usvg::Paint::RadialGradient(g) => {
329
+ let s = g.stops().first()?.color();
330
+ (s.red, s.green, s.blue)
331
+ }
332
+ usvg::Paint::Pattern(_) => return None,
333
+ };
334
+ Some(Color {
335
+ r: r as f32 / 255.0,
336
+ g: g as f32 / 255.0,
337
+ b: b as f32 / 255.0,
338
+ a: opacity,
339
+ })
340
+ }
341
+
342
+ if let Some(fill) = path.fill() {
343
+ return from_paint(fill.paint(), fill.opacity().get());
344
+ }
345
+ path.stroke()
346
+ .and_then(|s| from_paint(s.paint(), s.opacity().get()))
347
+ }
348
+
349
+ fn svg_to_paths(svg: &str, fill: Color) -> Result<Vec<PathNode>, FormulaError> {
350
+ let opts = usvg::Options::default();
351
+ let tree = usvg::Tree::from_str(svg, &opts).map_err(|e| FormulaError::Svg(e.to_string()))?;
352
+
353
+ let mut nodes = Vec::new();
354
+ collect_nodes(tree.root(), &mut nodes, fill);
355
+ Ok(nodes)
356
+ }
357
+
358
+ fn collect_nodes(group: &usvg::Group, nodes: &mut Vec<PathNode>, fill: Color) {
359
+ for node in group.children() {
360
+ match node {
361
+ usvg::Node::Path(path) => {
362
+ if !path.is_visible() {
363
+ continue;
364
+ }
365
+ let segments = extract_segments(path);
366
+ if segments.is_empty() {
367
+ continue;
368
+ }
369
+ nodes.push(
370
+ PathNode::new()
371
+ .path(codimate_core::Path {
372
+ segments,
373
+ closed: false,
374
+ })
375
+ .fill(fill),
376
+ );
377
+ }
378
+ usvg::Node::Group(g) => collect_nodes(g, nodes, fill),
379
+ _ => {}
380
+ }
381
+ }
382
+ }
383
+
384
+ fn extract_segments(path: &usvg::Path) -> Vec<Segment> {
385
+ use usvg::tiny_skia_path::{PathSegment, Stroke};
386
+
387
+ // A fraction bar, a radical's overbar and `\overline` come out of Typst as
388
+ // *stroked* lines with no fill. Filling one directly draws nothing — a line
389
+ // encloses no area — so outline the stroke and fill the outline instead.
390
+ // Without this every `\frac` renders as a numerator and a denominator with
391
+ // no bar between them.
392
+ let data = match (path.fill(), path.stroke()) {
393
+ (None, Some(s)) => {
394
+ let mut stroke = Stroke::default();
395
+ stroke.width = s.width().get();
396
+ path.data().stroke(&stroke, 1.0)
397
+ }
398
+ _ => None,
399
+ };
400
+ let data = data.as_ref().unwrap_or(path.data());
401
+
402
+ let transformed = match data.clone().transform(path.abs_transform()) {
403
+ Some(p) => p,
404
+ None => return Vec::new(),
405
+ };
406
+
407
+ let mut segments = Vec::new();
408
+ let mut current = Vec2::new(0.0, 0.0);
409
+
410
+ for seg in transformed.segments() {
411
+ match seg {
412
+ PathSegment::MoveTo(p) => {
413
+ let pos = Vec2::new(p.x, p.y);
414
+ segments.push(Segment::MoveTo(pos));
415
+ current = pos;
416
+ }
417
+ PathSegment::LineTo(p) => {
418
+ let pos = Vec2::new(p.x, p.y);
419
+ segments.push(Segment::Line(current, pos));
420
+ current = pos;
421
+ }
422
+ PathSegment::QuadTo(c, p) => {
423
+ let ctrl = Vec2::new(c.x, c.y);
424
+ let pos = Vec2::new(p.x, p.y);
425
+ segments.push(Segment::Quad(current, ctrl, pos));
426
+ current = pos;
427
+ }
428
+ PathSegment::CubicTo(c1, c2, p) => {
429
+ let ctrl1 = Vec2::new(c1.x, c1.y);
430
+ let ctrl2 = Vec2::new(c2.x, c2.y);
431
+ let pos = Vec2::new(p.x, p.y);
432
+ segments.push(Segment::Cubic(current, ctrl1, ctrl2, pos));
433
+ current = pos;
434
+ }
435
+ PathSegment::Close => {
436
+ segments.push(Segment::Close);
437
+ }
438
+ }
439
+ }
440
+
441
+ segments
442
+ }
443
+
444
+ #[cfg(test)]
445
+ mod tests {
446
+ use super::*;
447
+
448
+ /// `mitex` compiles some LaTeX into calls on its own Typst package, so a
449
+ /// preamble missing those helpers fails on ordinary formulas — `\sqrt`
450
+ /// most of all. See `MITEX_PRELUDE`.
451
+ ///
452
+ /// Skipped when `typst` is not installed (ADR 0005: it is an external
453
+ /// binary, not a build dependency).
454
+ #[test]
455
+ fn mitex_helpers_are_defined() {
456
+ if std::process::Command::new("typst")
457
+ .arg("--version")
458
+ .output()
459
+ .is_err()
460
+ {
461
+ eprintln!("skipping: typst not installed");
462
+ return;
463
+ }
464
+ for latex in [
465
+ r"\sqrt{x}",
466
+ r"\sqrt[3]{x}",
467
+ r"\frac{QK^{T}}{\sqrt{d_k}}",
468
+ r"\mathbf{W}",
469
+ r"a \! b",
470
+ r"\begin{bmatrix} a & b \\ c & d \end{bmatrix}",
471
+ ] {
472
+ let block =
473
+ formula(latex, Color::WHITE).unwrap_or_else(|e| panic!("{latex} failed: {e:?}"));
474
+ assert!(!block.glyphs.is_empty(), "{latex} produced no glyphs");
475
+ }
476
+ }
477
+
478
+ /// A fraction bar is a *stroked* line in Typst's SVG. Filling it directly
479
+ /// draws nothing, so `\frac` used to render with no bar at all. The bar
480
+ /// must enclose area — that is what makes it visible.
481
+ #[test]
482
+ fn a_fraction_has_a_visible_bar() {
483
+ if std::process::Command::new("typst")
484
+ .arg("--version")
485
+ .output()
486
+ .is_err()
487
+ {
488
+ eprintln!("skipping: typst not installed");
489
+ return;
490
+ }
491
+ let block = formula(r"\frac{a}{b}", Color::WHITE).unwrap();
492
+ let bar = block
493
+ .glyphs
494
+ .iter()
495
+ .filter_map(|g| g.resolve(0.0).path.bounding_box())
496
+ // The bar is the wide, flat one; `a` and `b` are roughly square.
497
+ .find(|(x0, y0, x1, y1)| (x1 - x0) > 3.0 * (y1 - y0).max(0.001));
498
+ let (_, y0, _, y1) = bar.expect("no fraction bar in the output at all");
499
+ assert!(
500
+ y1 - y0 > 0.0,
501
+ "the fraction bar is a zero-height line, so it fills to nothing"
502
+ );
503
+ }
504
+ }
505
+
506
+ #[cfg(test)]
507
+ mod svg_import_tests {
508
+ use super::*;
509
+
510
+ // `r##` because the artwork contains `"#` in every hex colour, which
511
+ // would close an `r#` string early.
512
+ const ART: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
513
+ <rect x="0" y="0" width="40" height="40" fill="#ff0000"/>
514
+ <path d="M60 10 L90 10" stroke="#00ff00" stroke-width="4" fill="none"/>
515
+ </svg>"##;
516
+
517
+ /// The whole point of importing rather than pasting a picture: each path
518
+ /// arrives with the colour it was drawn in (ADR 0014).
519
+ #[test]
520
+ fn each_path_keeps_the_colour_it_was_authored_in() {
521
+ let paths = import_svg(ART).expect("should import");
522
+ assert_eq!(paths.len(), 2, "a rect and a line");
523
+
524
+ let red = paths[0].fill.expect("the rect has a fill");
525
+ assert!((red.r - 1.0).abs() < 0.01 && red.g < 0.01, "{red:?}");
526
+
527
+ // A stroke-only path reports its stroke: `extract_segments` turned the
528
+ // stroke into a fillable outline, so the outline should be the colour
529
+ // the stroke was, not the fill it never had.
530
+ let green = paths[1].fill.expect("the line reports its stroke");
531
+ assert!(green.g > 0.99 && green.r < 0.01, "{green:?}");
532
+ }
533
+
534
+ /// Labels come back as text to shape later, not as outlines — that is
535
+ /// what keeps one text stack rather than two (ADR 0014).
536
+ #[test]
537
+ fn labels_are_read_with_their_placement() {
538
+ let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" font-size="12">
539
+ <text x="44" y="340" text-anchor="end" fill="#888">0.0</text>
540
+ </svg>"##;
541
+ let labels = import_svg_text(svg).expect("should read");
542
+ assert_eq!(labels.len(), 1);
543
+ assert_eq!(labels[0].content, "0.0");
544
+ assert_eq!((labels[0].x, labels[0].y), (44.0, 340.0));
545
+ assert_eq!(labels[0].anchor, "end");
546
+ // `font-size` is inherited from the root, which is where files put it.
547
+ assert_eq!(labels[0].size, 12.0);
548
+ assert!(labels[0].fill.is_some(), "#888 should parse");
549
+ }
550
+
551
+ /// Anything whose placement this does not implement is refused rather
552
+ /// than drawn in the wrong spot.
553
+ #[test]
554
+ fn layout_we_cannot_read_is_refused() {
555
+ for svg in [
556
+ r#"<svg xmlns="http://www.w3.org/2000/svg"><text><tspan>a</tspan></text></svg>"#,
557
+ r#"<svg xmlns="http://www.w3.org/2000/svg"><g transform="rotate(90)"><text x="1" y="2">a</text></g></svg>"#,
558
+ ] {
559
+ assert!(
560
+ matches!(import_svg_text(svg), Err(FormulaError::SvgText(_))),
561
+ "{svg}"
562
+ );
563
+ }
564
+ }
565
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "codimate-py"
3
- version = "0.1.1"
3
+ version = "0.1.2"
4
4
  edition = "2021"
5
5
 
6
6
  [lib]