1#[cfg(feature = "raster")]
23const EMBEDDED_FONT: &[u8] = include_bytes!("../fonts/Go-Regular.ttf");
24#[cfg(feature = "raster")]
25const EMBEDDED_FONT_BOLD: &[u8] = include_bytes!("../fonts/Go-Bold.ttf");
26#[cfg(feature = "raster")]
27const EMBEDDED_FONT_ITALIC: &[u8] = include_bytes!("../fonts/Go-Italic.ttf");
28#[cfg(feature = "raster")]
29const EMBEDDED_FONT_BOLD_ITALIC: &[u8] = include_bytes!("../fonts/Go-Bold-Italic.ttf");
30#[cfg(feature = "raster")]
31const EMBEDDED_FONT_MONO: &[u8] = include_bytes!("../fonts/Go-Mono.ttf");
32#[cfg(feature = "raster")]
33const EMBEDDED_FONT_MONO_BOLD: &[u8] = include_bytes!("../fonts/Go-Mono-Bold.ttf");
34#[cfg(feature = "raster")]
36const EMBEDDED_FONT_FAMILY: &str = "Go";
37#[cfg(feature = "raster")]
39const EMBEDDED_MONO_FAMILY: &str = "Go Mono";
40
41#[cfg(feature = "math")]
42pub mod math;
43
44#[cfg(feature = "raster")]
46pub const DEFAULT_MAX_RASTER_DIMENSION: u32 = 32_768;
47
48#[cfg(feature = "raster")]
50pub const DEFAULT_MAX_RASTER_PIXELS: u64 = 64_000_000;
51
52#[cfg(feature = "raster")]
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub struct RasterLimits {
56 pub max_width: u32,
57 pub max_height: u32,
58 pub max_pixels: u64,
59}
60
61#[cfg(feature = "raster")]
62impl RasterLimits {
63 pub const fn new(max_width: u32, max_height: u32, max_pixels: u64) -> Self {
64 Self {
65 max_width,
66 max_height,
67 max_pixels,
68 }
69 }
70}
71
72#[cfg(feature = "raster")]
73impl Default for RasterLimits {
74 fn default() -> Self {
75 Self {
76 max_width: DEFAULT_MAX_RASTER_DIMENSION,
77 max_height: DEFAULT_MAX_RASTER_DIMENSION,
78 max_pixels: DEFAULT_MAX_RASTER_PIXELS,
79 }
80 }
81}
82
83#[cfg(feature = "raster")]
86fn load_embedded_fonts(db: &mut resvg::usvg::fontdb::Database) {
87 for data in [
88 EMBEDDED_FONT,
89 EMBEDDED_FONT_BOLD,
90 EMBEDDED_FONT_ITALIC,
91 EMBEDDED_FONT_BOLD_ITALIC,
92 EMBEDDED_FONT_MONO,
93 EMBEDDED_FONT_MONO_BOLD,
94 ] {
95 db.load_font_data(data.to_vec());
96 }
97 db.set_serif_family(EMBEDDED_FONT_FAMILY);
98 db.set_sans_serif_family(EMBEDDED_FONT_FAMILY);
99 db.set_monospace_family(EMBEDDED_MONO_FAMILY);
100 db.set_cursive_family(EMBEDDED_FONT_FAMILY);
101 db.set_fantasy_family(EMBEDDED_FONT_FAMILY);
102}
103
104#[cfg(feature = "raster")]
105pub fn to_png(svg: &str, scale: f32) -> Result<Vec<u8>, String> {
106 to_png_with_limits(svg, scale, RasterLimits::default())
107}
108
109#[cfg(feature = "raster")]
111pub fn to_png_with_limits(svg: &str, scale: f32, limits: RasterLimits) -> Result<Vec<u8>, String> {
112 use resvg::{tiny_skia, usvg};
113
114 if !scale.is_finite() || scale <= 0.0 {
115 return Err("scale must be a positive finite number".into());
116 }
117
118 let mut opt = usvg::Options::default();
119 load_embedded_fonts(opt.fontdb_mut());
120 let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| e.to_string())?;
121
122 let size = tree.size();
123 let (w, h) = checked_raster_size(size.width(), size.height(), scale, limits)?;
124 let mut pixmap = tiny_skia::Pixmap::new(w, h).ok_or("failed to allocate pixmap")?;
125 resvg::render(
126 &tree,
127 tiny_skia::Transform::from_scale(scale, scale),
128 &mut pixmap.as_mut(),
129 );
130 pixmap.encode_png().map_err(|e| e.to_string())
131}
132
133#[cfg(feature = "raster")]
134fn checked_raster_size(
135 svg_width: f32,
136 svg_height: f32,
137 scale: f32,
138 limits: RasterLimits,
139) -> Result<(u32, u32), String> {
140 let width = checked_scaled_dimension(svg_width, scale, "width")?;
141 let height = checked_scaled_dimension(svg_height, scale, "height")?;
142 let pixels = u64::from(width) * u64::from(height);
143
144 if width > limits.max_width || height > limits.max_height || pixels > limits.max_pixels {
145 return Err(format!(
146 "raster output exceeds configured pixel limit: {width}x{height} ({pixels} pixels) exceeds max {}x{} or {} pixels",
147 limits.max_width, limits.max_height, limits.max_pixels
148 ));
149 }
150
151 Ok((width, height))
152}
153
154#[cfg(feature = "raster")]
155fn checked_scaled_dimension(value: f32, scale: f32, axis: &str) -> Result<u32, String> {
156 let scaled = f64::from(value) * f64::from(scale);
157 if !scaled.is_finite() {
158 return Err(format!("raster {axis} is not finite"));
159 }
160
161 let pixels = scaled.ceil().max(1.0);
162 if pixels > f64::from(u32::MAX) {
163 return Err(format!(
164 "raster output exceeds configured pixel limit: scaled {axis} {pixels:e} exceeds u32::MAX"
165 ));
166 }
167
168 Ok(pixels as u32)
169}
170
171#[cfg(feature = "raster")]
173pub fn to_pdf(svg: &str) -> Result<Vec<u8>, String> {
174 use svg2pdf::usvg;
175
176 let mut opt = usvg::Options::default();
177 load_embedded_fonts(opt.fontdb_mut());
178 let tree = usvg::Tree::from_str(svg, &opt).map_err(|e| e.to_string())?;
179
180 svg2pdf::to_pdf(
181 &tree,
182 svg2pdf::ConversionOptions::default(),
183 svg2pdf::PageOptions::default(),
184 )
185 .map_err(|e| e.to_string())
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[cfg(feature = "raster")]
193 const SVG: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"20\" viewBox=\"0 0 40 20\"><rect x=\"2\" y=\"2\" width=\"36\" height=\"16\" fill=\"none\" stroke=\"black\"/></svg>";
194 #[cfg(feature = "raster")]
195 const SVG_TEXT: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"40\" viewBox=\"0 0 120 40\" font-family=\"sans-serif\" font-size=\"16\"><text x=\"4\" y=\"24\">Hello world</text></svg>";
196
197 #[cfg(feature = "raster")]
198 #[test]
199 fn png_has_magic_and_scales() {
200 let one = to_png(SVG, 1.0).unwrap();
201 assert_eq!(&one[..4], &[0x89, 0x50, 0x4E, 0x47]); let two = to_png(SVG, 2.0).unwrap();
203 assert!(two.len() > one.len()); }
205
206 #[cfg(feature = "raster")]
207 #[test]
208 fn png_rejects_invalid_scale() {
209 assert!(to_png(SVG, 0.0).is_err());
210 assert!(to_png(SVG, f32::NAN).is_err());
211 }
212
213 #[cfg(feature = "raster")]
214 #[test]
215 fn png_rejects_huge_scale_before_allocation() {
216 let err = to_png(SVG, 100_000.0).unwrap_err();
217 assert!(err.contains("raster output exceeds configured pixel limit"));
218 }
219
220 #[cfg(feature = "raster")]
221 #[test]
222 fn png_respects_custom_raster_limits() {
223 let err = to_png_with_limits(SVG, 1.0, RasterLimits::new(100, 100, 100)).unwrap_err();
224 assert!(err.contains("40x20 (800 pixels)"), "{err}");
225
226 let png = to_png_with_limits(SVG, 1.0, RasterLimits::new(100, 100, 1_000)).unwrap();
227 assert_eq!(&png[..4], &[0x89, 0x50, 0x4E, 0x47]);
228 }
229
230 #[cfg(feature = "raster")]
231 #[test]
232 fn gradient_fill_rasterizes_offline() {
233 const GRAD: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"20\" viewBox=\"0 0 40 20\"><defs><linearGradient id=\"grad0\" gradientUnits=\"objectBoundingBox\" x1=\"0\" y1=\"0.5\" x2=\"1\" y2=\"0.5\"><stop offset=\"0\" stop-color=\"black\"/><stop offset=\"1\" stop-color=\"white\"/></linearGradient></defs><rect x=\"2\" y=\"2\" width=\"36\" height=\"16\" fill=\"url(#grad0)\"/></svg>";
237 const FLAT: &str = "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"40\" height=\"20\" viewBox=\"0 0 40 20\"><rect x=\"2\" y=\"2\" width=\"36\" height=\"16\" fill=\"gray\"/></svg>";
238 let grad = to_png(GRAD, 1.0).unwrap();
239 assert_eq!(&grad[..4], &[0x89, 0x50, 0x4E, 0x47]);
240 let flat = to_png(FLAT, 1.0).unwrap();
243 assert_ne!(grad, flat);
244 assert_eq!(&to_pdf(GRAD).unwrap()[..4], b"%PDF");
245 }
246
247 #[cfg(feature = "math")]
248 #[test]
249 fn math_fragment_root_is_unitless_px() {
250 let span = math::render_math("x", 11.0).unwrap();
254 let head = &span.svg[..span.svg.find('>').unwrap()];
255 assert!(!head.contains("pt\""), "{head}");
256 let w_attr: f64 = head
258 .split("width=\"")
259 .nth(1)
260 .and_then(|t| t.split('\"').next())
261 .unwrap()
262 .parse()
263 .unwrap();
264 assert!(
265 (w_attr - span.width * 96.0).abs() < 0.01,
266 "{w_attr} vs {}",
267 span.width * 96.0
268 );
269 }
270
271 #[cfg(all(feature = "math", feature = "raster"))]
272 #[test]
273 fn texlabels_math_renders_through_png_and_pdf() {
274 rpic_core::set_math_renderer(math::render_math);
278 let src = "texlabels = 1\nbox \"$-\\frac{T}{2}$\" wid 1 ht 0.7";
279 let d = rpic_core::compile(src).unwrap();
280 let svg = rpic_core::to_svg(&d);
281 assert!(svg.contains("<svg x=\""), "{svg}");
282 assert!(!svg.contains("frac"), "raw TeX must not leak: {svg}");
283
284 let png = to_png(&svg, 2.0).unwrap();
285 assert_eq!(&png[..4], &[0x89, 0x50, 0x4E, 0x47]);
286 let blank_d = rpic_core::compile("box wid 1 ht 0.7").unwrap();
289 let blank = to_png(&rpic_core::to_svg(&blank_d), 2.0).unwrap();
290 assert!(
291 png.len() > blank.len(),
292 "png {} <= blank {}",
293 png.len(),
294 blank.len()
295 );
296
297 assert_eq!(&to_pdf(&svg).unwrap()[..4], b"%PDF");
298 }
299
300 #[cfg(feature = "raster")]
301 #[test]
302 fn pdf_has_magic() {
303 let pdf = to_pdf(SVG).unwrap();
304 assert_eq!(&pdf[..4], b"%PDF");
305 }
306
307 #[cfg(feature = "raster")]
308 #[test]
309 fn bundled_font_rasterizes_text() {
310 let with_text = to_png(SVG_TEXT, 1.0).unwrap();
314 let blank = to_png(
315 "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"120\" height=\"40\" viewBox=\"0 0 120 40\"></svg>",
316 1.0,
317 )
318 .unwrap();
319 assert!(
320 with_text.len() > blank.len() + 200,
321 "text PNG {} vs blank {}",
322 with_text.len(),
323 blank.len()
324 );
325 }
326}