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.
- {codimate-0.1.1 → codimate-0.1.2}/Cargo.lock +2 -1
- {codimate-0.1.1 → codimate-0.1.2}/PKG-INFO +2 -1
- {codimate-0.1.1 → codimate-0.1.2}/README.md +1 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-export/src/lib.rs +8 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-math/Cargo.toml +5 -0
- codimate-0.1.2/crates/codimate-math/src/lib.rs +565 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-py/Cargo.toml +1 -1
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-reconcile/src/lib.rs +352 -73
- {codimate-0.1.1 → codimate-0.1.2}/pyproject.toml +1 -1
- {codimate-0.1.1 → codimate-0.1.2}/python/codimate/__init__.py +1 -1
- {codimate-0.1.1 → codimate-0.1.2}/python/codimate/scene.py +50 -2
- codimate-0.1.1/crates/codimate-math/src/lib.rs +0 -293
- {codimate-0.1.1 → codimate-0.1.2}/Cargo.toml +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/Cargo.toml +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/src/lib.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/animation.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/parallel.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/playable.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/sequence.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-animation/tests/stagger.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/Cargo.toml +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/builder.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/easing.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/glyph.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/lib.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/manim_palette.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/path.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/circle.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/connection.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/mod.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/path.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/primitive.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/pulse.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/rect.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/text.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/scene/transform.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/src/value.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/anchors.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/animated.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/circle.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/connection.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/easing.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/manim_palette.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/path.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/ports.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/pulse.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/rect.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/scene.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/scene_effect_helpers.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-core/tests/tween.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-export/Cargo.toml +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-export/tests/export.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-export/tests/png.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/Cargo.toml +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/build.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/fonts/DejaVuSansMono.ttf +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/fonts/DroidSansFallbackFull.ttf +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/fonts/NotoSansKhmer-Regular.ttf +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-fonts/src/lib.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-glyph/Cargo.toml +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-glyph/src/lib.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-layout/Cargo.toml +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-layout/src/lib.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-layout/tests/layout.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-py/src/lib.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-reconcile/Cargo.toml +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/Cargo.toml +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/src/command.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/src/lib.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/src/raster.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/tests/raster.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/crates/codimate-render/tests/render.rs +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/python/codimate/explain.py +0 -0
- {codimate-0.1.1 → codimate-0.1.2}/python/codimate/layout.py +0 -0
- {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.
|
|
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.
|
|
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
|
+
}
|