1#![forbid(unsafe_code)]
2
3#[cfg(feature = "native-fonts")]
11pub mod fonts;
12#[cfg(feature = "native-fonts")]
13pub use fonts::{
14 FontSource, NativeFontContext, NativeFontError, NativeFontLimits, NativeFontOptions,
15};
16
17#[cfg(any(
19 feature = "native-fonts",
20 feature = "system-fonts",
21 feature = "png",
22 feature = "jpeg",
23 feature = "pdf"
24))]
25#[must_use]
26pub const fn system_font_discovery_available() -> bool {
27 cfg!(feature = "system-fonts")
28}
29
30#[cfg(any(feature = "png", feature = "jpeg"))]
31use cssparser::{Delimiter, Parser, Token};
32#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
33use merman_core::{
34 OperationCancelled, OperationControl, OperationLedgerError, OperationPhase,
35 OperationResourceDomain, OperationResourceLimitExceeded, OperationResourceProvenance,
36};
37#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
38use merman_render::svg::ResvgCompatibleSvg;
39#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
40use std::sync::{Arc, OnceLock};
41
42#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
43#[derive(Debug, thiserror::Error)]
44pub enum ExportError {
45 #[cfg(feature = "native-fonts")]
46 #[error(transparent)]
47 NativeFont(#[from] NativeFontError),
48 #[error(transparent)]
49 Cancelled(#[from] OperationCancelled),
50 #[error("failed to parse SVG")]
51 SvgParse,
52 #[error("failed to set SVG Document size from tree")]
53 SvgDocSize,
54 #[error("failed to allocate pixmap for raster rendering")]
55 PixmapAlloc,
56 #[error("invalid raster scale; expected a finite positive number")]
57 InvalidScale,
58 #[error("invalid raster sizing option: {0}")]
59 InvalidSizing(&'static str),
60 #[error("failed to encode PNG")]
61 PngEncode,
62 #[error("invalid background color for JPG rendering")]
63 JpegBackground,
64 #[error("JPG rendering requires an opaque background color (e.g. white)")]
65 JpegOpaqueBackgroundRequired,
66 #[error("failed to encode JPG")]
67 JpegEncode,
68 #[error("JPG dimensions exceed the 65535-pixel encoder limit")]
69 JpegDimensionLimit,
70 #[error("failed to convert SVG to PDF")]
71 PdfConvert,
72 #[error("embedded image resource limit exceeded: {limit_name} is {actual}, maximum is {max}")]
73 EmbeddedImageLimit {
74 limit_name: &'static str,
75 actual: u64,
76 max: u64,
77 },
78 #[error("SVG conversion resource limit exceeded: {limit_name} is {actual}, maximum is {max}")]
79 SvgConversionLimit {
80 limit_name: &'static str,
81 actual: u64,
82 max: u64,
83 },
84 #[cfg(feature = "pdf")]
85 #[error(
86 "PDF filter image resource limit exceeded: requested pixels are {actual}, maximum is {max}"
87 )]
88 PdfFilterImageLimit { actual: u64, max: u64 },
89 #[error(
90 "operation resource limit `{limit_id}` exceeded during {phase}: actual={actual} maximum={max}"
91 )]
92 ResourceLimitTerminal {
93 limit_id: &'static str,
94 phase: &'static str,
95 actual: u64,
96 max: u64,
97 },
98 #[error(
99 "operation resource `{limit_id}` arithmetic overflow during {phase}: actual={actual} maximum={max}"
100 )]
101 ResourceArithmeticOverflow {
102 limit_id: &'static str,
103 phase: &'static str,
104 actual: u64,
105 max: u64,
106 },
107 #[doc(hidden)]
108 #[error(transparent)]
109 OperationResourceTerminal(OperationLedgerError),
110 #[error("failed to start the recursive SVG backend worker")]
111 BackendWorkerSpawn,
112 #[error("the recursive SVG backend worker panicked")]
113 BackendWorkerPanic,
114}
115
116#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119#[non_exhaustive]
120pub struct ExportResourceLimitDetails {
121 pub limit_id: &'static str,
122 pub phase: &'static str,
123 pub actual: u64,
124 pub max: u64,
125 pub cause: ExportResourceLimitCause,
126}
127
128#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131#[non_exhaustive]
132pub enum ExportResourceLimitCause {
133 Ceiling,
134 ArithmeticOverflow,
135}
136
137#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
138impl ExportError {
139 #[must_use]
141 pub fn resource_limit_details(&self) -> Option<ExportResourceLimitDetails> {
142 let (limit_id, phase, actual, max, cause) = match self {
143 Self::EmbeddedImageLimit {
144 limit_name,
145 actual,
146 max,
147 } => {
148 let limit_id = match *limit_name {
149 "max_bytes_per_image" => MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID,
150 "max_total_bytes" => MAX_TOTAL_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID,
151 "max_pixels_per_image" => MAX_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
152 "max_total_pixels" => MAX_TOTAL_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
153 _ => return None,
154 };
155 (
156 limit_id,
157 "embedded_image_decode",
158 *actual,
159 *max,
160 ExportResourceLimitCause::Ceiling,
161 )
162 }
163 Self::SvgConversionLimit {
164 limit_name,
165 actual,
166 max,
167 } => {
168 let (limit_id, phase) = match *limit_name {
169 "max_isolation_depth" => (
170 MAX_SVG_CONVERSION_ISOLATION_DEPTH_RESOURCE_LIMIT_ID,
171 "svg_conversion",
172 ),
173 "max_filter_primitives_per_filter" => (
174 MAX_SVG_CONVERSION_FILTER_PRIMITIVES_PER_FILTER_RESOURCE_LIMIT_ID,
175 "svg_conversion",
176 ),
177 "max_total_filter_primitives" => (
178 MAX_TOTAL_SVG_CONVERSION_FILTER_PRIMITIVES_RESOURCE_LIMIT_ID,
179 "svg_conversion",
180 ),
181 "max_subroots" => (
182 MAX_SVG_CONVERSION_SUBROOTS_RESOURCE_LIMIT_ID,
183 "svg_conversion",
184 ),
185 "max_nested_svg_images" => {
186 (MAX_NESTED_SVG_IMAGES_RESOURCE_LIMIT_ID, "svg_conversion")
187 }
188 limit_id @ merman_render::resources::SVG_BACKEND_TREE_NODES_HARD_CAP_ID => (
189 limit_id,
190 merman_render::resources::ResourceLimitPhase::SvgPostprocess.as_str(),
191 ),
192 limit_id @ merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID => (
193 limit_id,
194 merman_render::resources::ResourceLimitPhase::SvgPostprocess.as_str(),
195 ),
196 _ => return None,
197 };
198 (
199 limit_id,
200 phase,
201 *actual,
202 *max,
203 ExportResourceLimitCause::Ceiling,
204 )
205 }
206 #[cfg(feature = "pdf")]
207 Self::PdfFilterImageLimit { actual, max } => (
208 MAX_PDF_FILTER_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
209 "pdf_filter_rasterization",
210 *actual,
211 *max,
212 ExportResourceLimitCause::Ceiling,
213 ),
214 Self::ResourceLimitTerminal {
215 limit_id,
216 phase,
217 actual,
218 max,
219 } => (
220 *limit_id,
221 *phase,
222 *actual,
223 *max,
224 ExportResourceLimitCause::Ceiling,
225 ),
226 Self::ResourceArithmeticOverflow {
227 limit_id,
228 phase,
229 actual,
230 max,
231 } => (
232 *limit_id,
233 *phase,
234 *actual,
235 *max,
236 ExportResourceLimitCause::ArithmeticOverflow,
237 ),
238 Self::OperationResourceTerminal(error) => {
239 return operation_resource_limit_details(error);
240 }
241 _ => return None,
242 };
243 Some(ExportResourceLimitDetails {
244 limit_id,
245 phase,
246 actual,
247 max,
248 cause,
249 })
250 }
251
252 #[must_use]
254 pub fn resource_limit_provenance(&self) -> Option<OperationResourceProvenance> {
255 match self {
256 Self::OperationResourceTerminal(OperationLedgerError::Cancelled(_)) => None,
257 Self::OperationResourceTerminal(OperationLedgerError::ResourceLimitExceeded(error)) => {
258 Some(error.provenance.clone())
259 }
260 Self::OperationResourceTerminal(OperationLedgerError::ArithmeticOverflow {
261 provenance,
262 ..
263 }) => Some(provenance.clone()),
264 _ => self
265 .resource_limit_details()
266 .map(|details| export_operation_resource_provenance(details.limit_id)),
267 }
268 }
269}
270
271#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
272fn operation_resource_limit_details(
273 error: &OperationLedgerError,
274) -> Option<ExportResourceLimitDetails> {
275 match error {
276 OperationLedgerError::Cancelled(_) => None,
277 OperationLedgerError::ResourceLimitExceeded(error) => Some(ExportResourceLimitDetails {
278 limit_id: error.id,
279 phase: error.resource_phase,
280 actual: error.consumed.saturating_add(error.requested),
281 max: error.limit,
282 cause: ExportResourceLimitCause::Ceiling,
283 }),
284 OperationLedgerError::ArithmeticOverflow {
285 id,
286 resource_phase,
287 actual,
288 maximum,
289 ..
290 } => Some(ExportResourceLimitDetails {
291 limit_id: id,
292 phase: resource_phase,
293 actual: *actual,
294 max: *maximum,
295 cause: ExportResourceLimitCause::ArithmeticOverflow,
296 }),
297 }
298}
299
300#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
301pub type Result<T> = std::result::Result<T, ExportError>;
302
303#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
304fn export_checkpoint(control: &OperationControl) -> Result<()> {
305 control
306 .terminal_checkpoint_at(OperationPhase::Export)
307 .map_err(export_terminal_error)
308}
309
310#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
311fn terminate_export_resource_error(control: &OperationControl, error: ExportError) -> ExportError {
312 let Some(details) = error.resource_limit_details() else {
313 return error;
314 };
315 let Some(provenance) = error.resource_limit_provenance() else {
316 return error;
317 };
318 if let Err(terminal) = control.terminal_checkpoint_at(OperationPhase::Export) {
319 return export_terminal_error(terminal);
320 }
321 let (terminal, expected) = match details.cause {
322 ExportResourceLimitCause::Ceiling => {
323 let error = OperationResourceLimitExceeded {
324 id: details.limit_id,
325 phase: OperationPhase::Export,
326 resource_phase: details.phase,
327 limit: details.max,
328 consumed: 0,
329 requested: details.actual,
330 provenance: provenance.clone(),
331 };
332 (
333 control.terminate_resource_limit(error.clone()),
334 OperationLedgerError::ResourceLimitExceeded(error),
335 )
336 }
337 ExportResourceLimitCause::ArithmeticOverflow => {
338 let expected = OperationLedgerError::ArithmeticOverflow {
339 id: details.limit_id,
340 phase: OperationPhase::Export,
341 resource_phase: details.phase,
342 actual: details.actual,
343 maximum: details.max,
344 provenance: provenance.clone(),
345 };
346 (
347 control.terminate_resource_overflow(
348 details.limit_id,
349 OperationPhase::Export,
350 details.phase,
351 details.actual,
352 details.max,
353 provenance,
354 ),
355 expected,
356 )
357 }
358 };
359 if terminal == expected {
360 error
361 } else {
362 export_terminal_error(terminal)
363 }
364}
365
366#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
367fn export_operation_resource_provenance(limit_id: &str) -> OperationResourceProvenance {
368 let domain = match limit_id {
369 merman_render::resources::SVG_BACKEND_TREE_NODES_HARD_CAP_ID
370 | merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID => {
371 OperationResourceDomain::Render
372 }
373 _ => OperationResourceDomain::Export,
374 };
375 OperationResourceProvenance::new(domain, None, [])
376}
377
378#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
379fn settle_export_result<T>(control: &OperationControl, result: Result<T>) -> Result<T> {
380 let result = result.map_err(|error| terminate_export_resource_error(control, error));
381 observe_after_export_result(control, result)
382}
383
384#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
385fn observe_after_export_result<T>(control: &OperationControl, result: Result<T>) -> Result<T> {
386 match result {
387 Err(error) if error.resource_limit_details().is_some() => Err(error),
388 result => {
389 export_checkpoint(control)?;
390 result
391 }
392 }
393}
394
395#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
396fn export_terminal_error(error: OperationLedgerError) -> ExportError {
397 match error {
398 OperationLedgerError::Cancelled(error) => ExportError::Cancelled(error),
399 terminal @ (OperationLedgerError::ResourceLimitExceeded(_)
400 | OperationLedgerError::ArithmeticOverflow { .. }) => {
401 ExportError::OperationResourceTerminal(terminal)
402 }
403 }
404}
405
406#[cfg(any(feature = "png", feature = "jpeg"))]
407pub const DEFAULT_MAX_RASTER_SIDE_LENGTH: u32 = 4096;
408#[cfg(any(feature = "png", feature = "jpeg"))]
409pub const DEFAULT_MAX_RASTER_PIXELS: u64 =
410 (DEFAULT_MAX_RASTER_SIDE_LENGTH as u64) * (DEFAULT_MAX_RASTER_SIDE_LENGTH as u64);
411#[cfg(feature = "pdf")]
413pub const DEFAULT_MAX_PDF_FILTER_IMAGE_PIXELS: u64 = 32 * 1024 * 1024;
414#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
416pub const DEFAULT_MAX_DECODED_IMAGE_PIXELS: u64 = 16 * 1024 * 1024;
417#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
419pub const DEFAULT_MAX_TOTAL_DECODED_IMAGE_PIXELS: u64 = 32 * 1024 * 1024;
420#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
422pub const DEFAULT_MAX_EMBEDDED_IMAGE_BYTES: u64 = 16 * 1024 * 1024;
423#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
425pub const DEFAULT_MAX_TOTAL_EMBEDDED_IMAGE_BYTES: u64 = 32 * 1024 * 1024;
426#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
427pub const DEFAULT_MAX_SVG_ISOLATION_DEPTH: usize = 8;
428#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
429pub const DEFAULT_MAX_FILTER_PRIMITIVES_PER_FILTER: usize = 8;
430#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
431pub const DEFAULT_MAX_TOTAL_FILTER_PRIMITIVES: usize = 128;
432#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
433pub const DEFAULT_MAX_SVG_SUBROOTS: usize = 4096;
434#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
435pub const DEFAULT_MAX_NESTED_SVG_IMAGES: usize = 64;
436
437#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440#[non_exhaustive]
441pub struct ExportResourceLimitDescriptor {
442 pub stable_id: &'static str,
443 pub phase: &'static str,
444 pub description: &'static str,
445 pub overridable: bool,
446 pub hard_cap: bool,
447 pub minimum_value: usize,
448}
449
450#[cfg(any(feature = "png", feature = "jpeg"))]
451pub const MAX_RASTER_WIDTH_RESOURCE_LIMIT_ID: &str = "max_raster_width";
452#[cfg(any(feature = "png", feature = "jpeg"))]
453pub const MAX_RASTER_HEIGHT_RESOURCE_LIMIT_ID: &str = "max_raster_height";
454#[cfg(any(feature = "png", feature = "jpeg"))]
455pub const MAX_RASTER_PIXELS_RESOURCE_LIMIT_ID: &str = "max_raster_pixels";
456#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
457pub const MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID: &str = "max_embedded_image_bytes";
458#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
459pub const MAX_TOTAL_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID: &str = "max_total_embedded_image_bytes";
460#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
461pub const MAX_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID: &str = "max_embedded_image_pixels";
462#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
463pub const MAX_TOTAL_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID: &str =
464 "max_total_embedded_image_pixels";
465#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
466pub const MAX_SVG_CONVERSION_ISOLATION_DEPTH_RESOURCE_LIMIT_ID: &str =
467 "max_svg_conversion_isolation_depth";
468#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
469pub const MAX_SVG_CONVERSION_FILTER_PRIMITIVES_PER_FILTER_RESOURCE_LIMIT_ID: &str =
470 "max_svg_conversion_filter_primitives_per_filter";
471#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
472pub const MAX_TOTAL_SVG_CONVERSION_FILTER_PRIMITIVES_RESOURCE_LIMIT_ID: &str =
473 "max_total_svg_conversion_filter_primitives";
474#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
475pub const MAX_SVG_CONVERSION_SUBROOTS_RESOURCE_LIMIT_ID: &str = "max_svg_conversion_subroots";
476#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
477pub const MAX_NESTED_SVG_IMAGES_RESOURCE_LIMIT_ID: &str = "max_nested_svg_images";
478#[cfg(feature = "pdf")]
479pub const MAX_PDF_FILTER_IMAGE_PIXELS_RESOURCE_LIMIT_ID: &str = "max_pdf_filter_image_pixels";
480#[cfg(any(feature = "png", feature = "jpeg"))]
481const RASTER_RESOURCE_LIMIT_DESCRIPTORS: [ExportResourceLimitDescriptor; 3] = [
482 ExportResourceLimitDescriptor {
483 stable_id: MAX_RASTER_WIDTH_RESOURCE_LIMIT_ID,
484 phase: "raster_allocation",
485 description: "Maximum final PNG or JPEG width in pixels",
486 overridable: true,
487 hard_cap: false,
488 minimum_value: 1,
489 },
490 ExportResourceLimitDescriptor {
491 stable_id: MAX_RASTER_HEIGHT_RESOURCE_LIMIT_ID,
492 phase: "raster_allocation",
493 description: "Maximum final PNG or JPEG height in pixels",
494 overridable: true,
495 hard_cap: false,
496 minimum_value: 1,
497 },
498 ExportResourceLimitDescriptor {
499 stable_id: MAX_RASTER_PIXELS_RESOURCE_LIMIT_ID,
500 phase: "raster_allocation",
501 description: "Maximum final PNG or JPEG pixel count",
502 overridable: true,
503 hard_cap: false,
504 minimum_value: 1,
505 },
506];
507
508#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
509const EMBEDDED_IMAGE_RESOURCE_LIMIT_DESCRIPTORS: [ExportResourceLimitDescriptor; 4] = [
510 ExportResourceLimitDescriptor {
511 stable_id: MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID,
512 phase: "embedded_image_decode",
513 description: "Maximum decoded data-URL bytes for one embedded image",
514 overridable: true,
515 hard_cap: false,
516 minimum_value: 1,
517 },
518 ExportResourceLimitDescriptor {
519 stable_id: MAX_TOTAL_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID,
520 phase: "embedded_image_decode",
521 description: "Maximum aggregate decoded data-URL bytes across embedded images",
522 overridable: true,
523 hard_cap: false,
524 minimum_value: 1,
525 },
526 ExportResourceLimitDescriptor {
527 stable_id: MAX_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
528 phase: "embedded_image_decode",
529 description: "Maximum intrinsic pixels for one embedded raster image",
530 overridable: true,
531 hard_cap: false,
532 minimum_value: 1,
533 },
534 ExportResourceLimitDescriptor {
535 stable_id: MAX_TOTAL_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
536 phase: "embedded_image_decode",
537 description: "Maximum aggregate intrinsic pixels across embedded raster images",
538 overridable: true,
539 hard_cap: false,
540 minimum_value: 1,
541 },
542];
543
544#[cfg(feature = "pdf")]
545const PDF_RESOURCE_LIMIT_DESCRIPTORS: [ExportResourceLimitDescriptor; 1] =
546 [ExportResourceLimitDescriptor {
547 stable_id: MAX_PDF_FILTER_IMAGE_PIXELS_RESOURCE_LIMIT_ID,
548 phase: "pdf_filter_rasterization",
549 description: "Maximum aggregate pixels retained for localized PDF filter images",
550 overridable: true,
551 hard_cap: false,
552 minimum_value: 1,
553 }];
554
555#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
558const SVG_CONVERSION_HARD_CAP_DESCRIPTORS: [ExportResourceLimitDescriptor; 5] = [
559 ExportResourceLimitDescriptor {
560 stable_id: MAX_SVG_CONVERSION_ISOLATION_DEPTH_RESOURCE_LIMIT_ID,
561 phase: "svg_conversion",
562 description: "Maximum nested SVG isolation depth accepted by native export",
563 overridable: false,
564 hard_cap: true,
565 minimum_value: 1,
566 },
567 ExportResourceLimitDescriptor {
568 stable_id: MAX_SVG_CONVERSION_FILTER_PRIMITIVES_PER_FILTER_RESOURCE_LIMIT_ID,
569 phase: "svg_conversion",
570 description: "Maximum primitives accepted in one SVG filter",
571 overridable: false,
572 hard_cap: true,
573 minimum_value: 1,
574 },
575 ExportResourceLimitDescriptor {
576 stable_id: MAX_TOTAL_SVG_CONVERSION_FILTER_PRIMITIVES_RESOURCE_LIMIT_ID,
577 phase: "svg_conversion",
578 description: "Maximum aggregate SVG filter primitives accepted by native export",
579 overridable: false,
580 hard_cap: true,
581 minimum_value: 1,
582 },
583 ExportResourceLimitDescriptor {
584 stable_id: MAX_SVG_CONVERSION_SUBROOTS_RESOURCE_LIMIT_ID,
585 phase: "svg_conversion",
586 description: "Maximum resolved SVG subroots accepted by native export",
587 overridable: false,
588 hard_cap: true,
589 minimum_value: 1,
590 },
591 ExportResourceLimitDescriptor {
592 stable_id: MAX_NESTED_SVG_IMAGES_RESOURCE_LIMIT_ID,
593 phase: "svg_conversion",
594 description: "Maximum nested SVG images accepted by native export",
595 overridable: false,
596 hard_cap: true,
597 minimum_value: 1,
598 },
599];
600
601#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
603#[must_use]
604pub fn export_resource_limit_descriptors() -> Vec<ExportResourceLimitDescriptor> {
605 let mut descriptors = Vec::new();
606 #[cfg(any(feature = "png", feature = "jpeg"))]
607 descriptors.extend_from_slice(&RASTER_RESOURCE_LIMIT_DESCRIPTORS);
608 descriptors.extend_from_slice(&EMBEDDED_IMAGE_RESOURCE_LIMIT_DESCRIPTORS);
609 #[cfg(feature = "pdf")]
610 descriptors.extend_from_slice(&PDF_RESOURCE_LIMIT_DESCRIPTORS);
611 descriptors.extend_from_slice(&SVG_CONVERSION_HARD_CAP_DESCRIPTORS);
612 descriptors
613}
614
615#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
617#[must_use]
618pub fn export_resource_limit_output_ids(stable_id: &str) -> Option<&'static [&'static str]> {
619 #[cfg(any(feature = "png", feature = "jpeg"))]
620 if RASTER_RESOURCE_LIMIT_DESCRIPTORS
621 .iter()
622 .any(|descriptor| descriptor.stable_id == stable_id)
623 {
624 return Some(&["png", "jpeg"]);
625 }
626 if EMBEDDED_IMAGE_RESOURCE_LIMIT_DESCRIPTORS
627 .iter()
628 .any(|descriptor| descriptor.stable_id == stable_id)
629 {
630 return Some(&["png", "jpeg", "pdf"]);
631 }
632 #[cfg(feature = "pdf")]
633 if PDF_RESOURCE_LIMIT_DESCRIPTORS
634 .iter()
635 .any(|descriptor| descriptor.stable_id == stable_id)
636 {
637 return Some(&["pdf"]);
638 }
639 if SVG_CONVERSION_HARD_CAP_DESCRIPTORS
640 .iter()
641 .any(|descriptor| descriptor.stable_id == stable_id)
642 {
643 return Some(&["png", "jpeg", "pdf"]);
644 }
645 None
646}
647
648#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
650#[must_use]
651pub fn export_resource_profile_value(
652 profile: merman_render::resources::RenderResourceProfile,
653 stable_id: &str,
654) -> Option<Option<usize>> {
655 let finite_value = match stable_id {
656 #[cfg(any(feature = "png", feature = "jpeg"))]
657 MAX_RASTER_WIDTH_RESOURCE_LIMIT_ID | MAX_RASTER_HEIGHT_RESOURCE_LIMIT_ID => {
658 DEFAULT_MAX_RASTER_SIDE_LENGTH as usize
659 }
660 #[cfg(any(feature = "png", feature = "jpeg"))]
661 MAX_RASTER_PIXELS_RESOURCE_LIMIT_ID => DEFAULT_MAX_RASTER_PIXELS as usize,
662 MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID => DEFAULT_MAX_EMBEDDED_IMAGE_BYTES as usize,
663 MAX_TOTAL_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID => {
664 DEFAULT_MAX_TOTAL_EMBEDDED_IMAGE_BYTES as usize
665 }
666 MAX_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID => DEFAULT_MAX_DECODED_IMAGE_PIXELS as usize,
667 MAX_TOTAL_EMBEDDED_IMAGE_PIXELS_RESOURCE_LIMIT_ID => {
668 DEFAULT_MAX_TOTAL_DECODED_IMAGE_PIXELS as usize
669 }
670 #[cfg(feature = "pdf")]
671 MAX_PDF_FILTER_IMAGE_PIXELS_RESOURCE_LIMIT_ID => {
672 DEFAULT_MAX_PDF_FILTER_IMAGE_PIXELS as usize
673 }
674 MAX_SVG_CONVERSION_ISOLATION_DEPTH_RESOURCE_LIMIT_ID => DEFAULT_MAX_SVG_ISOLATION_DEPTH,
675 MAX_SVG_CONVERSION_FILTER_PRIMITIVES_PER_FILTER_RESOURCE_LIMIT_ID => {
676 DEFAULT_MAX_FILTER_PRIMITIVES_PER_FILTER
677 }
678 MAX_TOTAL_SVG_CONVERSION_FILTER_PRIMITIVES_RESOURCE_LIMIT_ID => {
679 DEFAULT_MAX_TOTAL_FILTER_PRIMITIVES
680 }
681 MAX_SVG_CONVERSION_SUBROOTS_RESOURCE_LIMIT_ID => DEFAULT_MAX_SVG_SUBROOTS,
682 MAX_NESTED_SVG_IMAGES_RESOURCE_LIMIT_ID => DEFAULT_MAX_NESTED_SVG_IMAGES,
683 _ => return None,
684 };
685 if SVG_CONVERSION_HARD_CAP_DESCRIPTORS
686 .iter()
687 .any(|descriptor| descriptor.stable_id == stable_id)
688 {
689 return Some(Some(finite_value));
690 }
691 Some(match profile {
692 merman_render::resources::RenderResourceProfile::UnboundedForTrustedInput => None,
693 merman_render::resources::RenderResourceProfile::Interactive
694 | merman_render::resources::RenderResourceProfile::Constrained
695 | merman_render::resources::RenderResourceProfile::TrustedNative => Some(finite_value),
696 })
697}
698
699#[cfg(feature = "pdf")]
700const PDF_POINTS_PER_CSS_PIXEL: f32 = 72.0 / 96.0;
701#[cfg(feature = "pdf")]
702const KRILLA_MAX_FILTER_SIDE_PX: f64 = 5000.0;
703#[cfg(all(
704 any(feature = "png", feature = "jpeg", feature = "pdf"),
705 not(target_arch = "wasm32")
706))]
707const RECURSIVE_SVG_BACKEND_STACK_BYTES: usize = 8 * 1024 * 1024;
708
709#[cfg(all(
710 any(feature = "png", feature = "jpeg", feature = "pdf"),
711 target_arch = "wasm32"
712))]
713const RECURSIVE_SVG_BACKEND_STACK_BYTES: usize = 0;
714
715#[cfg(all(
716 any(feature = "png", feature = "jpeg", feature = "pdf"),
717 not(target_arch = "wasm32")
718))]
719fn run_recursive_svg_backend<T, F>(control: &OperationControl, job: F) -> Result<T>
720where
721 T: Send + 'static,
722 F: FnOnce(&OperationControl) -> Result<T> + Send + 'static,
723{
724 export_checkpoint(control)?;
725 let worker_control = control.clone();
726 let worker = std::thread::Builder::new()
727 .name("merman-svg-backend".to_string())
728 .stack_size(RECURSIVE_SVG_BACKEND_STACK_BYTES)
729 .spawn(move || {
730 export_checkpoint(&worker_control)?;
731 settle_export_result(&worker_control, job(&worker_control))
732 });
733 let worker = match worker {
734 Ok(worker) => worker,
735 Err(_) => {
736 export_checkpoint(control)?;
737 return Err(ExportError::BackendWorkerSpawn);
738 }
739 };
740 let result = worker
741 .join()
742 .unwrap_or(Err(ExportError::BackendWorkerPanic));
743 observe_after_export_result(control, result)
744}
745
746#[cfg(all(
747 any(feature = "png", feature = "jpeg", feature = "pdf"),
748 target_arch = "wasm32"
749))]
750fn run_recursive_svg_backend<T, F>(control: &OperationControl, job: F) -> Result<T>
751where
752 T: Send + 'static,
753 F: FnOnce(&OperationControl) -> Result<T> + Send + 'static,
754{
755 export_checkpoint(control)?;
756 settle_export_result(control, job(control))
757}
758
759#[cfg(any(feature = "png", feature = "jpeg"))]
765#[derive(Debug, Clone, Copy, PartialEq, Eq)]
766pub struct RasterFitBox {
767 pub width: Option<u32>,
768 pub height: Option<u32>,
769}
770
771#[cfg(any(feature = "png", feature = "jpeg"))]
772impl RasterFitBox {
773 pub const fn new(width: Option<u32>, height: Option<u32>) -> Self {
774 Self { width, height }
775 }
776
777 pub const fn width(width: u32) -> Self {
778 Self {
779 width: Some(width),
780 height: None,
781 }
782 }
783
784 pub const fn height(height: u32) -> Self {
785 Self {
786 width: None,
787 height: Some(height),
788 }
789 }
790
791 pub const fn contain(width: u32, height: u32) -> Self {
792 Self {
793 width: Some(width),
794 height: Some(height),
795 }
796 }
797}
798
799#[cfg(any(feature = "png", feature = "jpeg"))]
801#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub struct RasterSizeLimit {
803 pub max_width: Option<u32>,
804 pub max_height: Option<u32>,
805 pub max_pixels: Option<u64>,
806}
807
808#[cfg(any(feature = "png", feature = "jpeg"))]
809impl RasterSizeLimit {
810 pub const fn new(
811 max_width: Option<u32>,
812 max_height: Option<u32>,
813 max_pixels: Option<u64>,
814 ) -> Self {
815 Self {
816 max_width,
817 max_height,
818 max_pixels,
819 }
820 }
821
822 pub const fn max_side_length(max_side_length: u32) -> Self {
823 Self {
824 max_width: Some(max_side_length),
825 max_height: Some(max_side_length),
826 max_pixels: None,
827 }
828 }
829
830 pub const fn default_safe() -> Self {
831 Self {
832 max_width: Some(DEFAULT_MAX_RASTER_SIDE_LENGTH),
833 max_height: Some(DEFAULT_MAX_RASTER_SIDE_LENGTH),
834 max_pixels: Some(DEFAULT_MAX_RASTER_PIXELS),
835 }
836 }
837
838 pub const fn unbounded() -> Self {
839 Self {
840 max_width: None,
841 max_height: None,
842 max_pixels: None,
843 }
844 }
845}
846
847#[cfg(any(feature = "png", feature = "jpeg"))]
848impl Default for RasterSizeLimit {
849 fn default() -> Self {
850 Self::default_safe()
851 }
852}
853
854#[cfg(any(feature = "png", feature = "jpeg"))]
855#[derive(Debug, Clone)]
856pub struct RasterOptions {
857 pub scale: f32,
858 pub background: Option<String>,
859 pub jpeg_quality: u8,
860 pub fit_to: Option<RasterFitBox>,
861 pub size_limit: RasterSizeLimit,
862 pub embedded_image_limit: EmbeddedImageLimit,
863 pub conversion_limits: SvgConversionLimits,
864 #[cfg(feature = "native-fonts")]
865 pub font_context: Option<NativeFontContext>,
866}
867
868#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
870#[derive(Debug, Clone, Copy, PartialEq, Eq)]
871pub struct EmbeddedImageLimit {
872 pub max_bytes_per_image: Option<u64>,
873 pub max_total_bytes: Option<u64>,
874 pub max_pixels_per_image: Option<u64>,
875 pub max_total_pixels: Option<u64>,
876}
877
878#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
879impl EmbeddedImageLimit {
880 pub const fn new(
881 max_bytes_per_image: Option<u64>,
882 max_total_bytes: Option<u64>,
883 max_pixels_per_image: Option<u64>,
884 max_total_pixels: Option<u64>,
885 ) -> Self {
886 Self {
887 max_bytes_per_image,
888 max_total_bytes,
889 max_pixels_per_image,
890 max_total_pixels,
891 }
892 }
893
894 pub const fn default_safe() -> Self {
895 Self {
896 max_bytes_per_image: Some(DEFAULT_MAX_EMBEDDED_IMAGE_BYTES),
897 max_total_bytes: Some(DEFAULT_MAX_TOTAL_EMBEDDED_IMAGE_BYTES),
898 max_pixels_per_image: Some(DEFAULT_MAX_DECODED_IMAGE_PIXELS),
899 max_total_pixels: Some(DEFAULT_MAX_TOTAL_DECODED_IMAGE_PIXELS),
900 }
901 }
902
903 pub const fn unbounded() -> Self {
904 Self {
905 max_bytes_per_image: None,
906 max_total_bytes: None,
907 max_pixels_per_image: None,
908 max_total_pixels: None,
909 }
910 }
911}
912
913#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
914impl Default for EmbeddedImageLimit {
915 fn default() -> Self {
916 Self::default_safe()
917 }
918}
919
920#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
922#[derive(Debug, Clone, Copy, PartialEq, Eq)]
923#[non_exhaustive]
924pub struct SystemFontEnvironmentContract {
925 pub source_id: &'static str,
926 pub discovery: &'static str,
927 pub cache_scope: &'static str,
928 pub host_dependent: bool,
929 pub resource_bounded: bool,
930}
931
932#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
934#[derive(Debug, Clone, Copy, PartialEq, Eq)]
935#[non_exhaustive]
936pub struct EmbeddedImageEnvironmentContract {
937 pub source_ids: &'static [&'static str],
938 pub filesystem_access: bool,
939 pub network_access: bool,
940 pub default_limits: EmbeddedImageLimit,
941}
942
943#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
945#[derive(Debug, Clone, Copy, PartialEq, Eq)]
946#[non_exhaustive]
947pub struct ExportEnvironmentContract {
948 pub system_fonts: Option<SystemFontEnvironmentContract>,
950 pub embedded_images: EmbeddedImageEnvironmentContract,
951}
952
953#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
957#[must_use]
958pub fn output_environment_contract(output_id: &str) -> Option<ExportEnvironmentContract> {
959 let compiled = match output_id {
960 #[cfg(feature = "png")]
961 "png" => true,
962 #[cfg(feature = "jpeg")]
963 "jpeg" => true,
964 #[cfg(feature = "pdf")]
965 "pdf" => true,
966 _ => false,
967 };
968 compiled.then_some(ExportEnvironmentContract {
969 system_fonts: cfg!(not(target_arch = "wasm32")).then_some(SystemFontEnvironmentContract {
970 source_id: "host-system",
971 discovery: "first-use",
972 cache_scope: "process-global",
973 host_dependent: true,
974 resource_bounded: false,
975 }),
976 embedded_images: EmbeddedImageEnvironmentContract {
977 source_ids: &["data-url"],
978 filesystem_access: false,
979 network_access: false,
980 default_limits: EmbeddedImageLimit::default_safe(),
981 },
982 })
983}
984
985#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
989#[derive(Debug, Clone, Copy, PartialEq, Eq)]
990pub struct SvgConversionLimits {
991 pub max_isolation_depth: Option<usize>,
992 pub max_filter_primitives_per_filter: Option<usize>,
993 pub max_total_filter_primitives: Option<usize>,
994 pub max_subroots: Option<usize>,
995 pub max_nested_svg_images: Option<usize>,
996}
997
998#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
999impl SvgConversionLimits {
1000 pub const fn default_safe() -> Self {
1001 Self {
1002 max_isolation_depth: Some(DEFAULT_MAX_SVG_ISOLATION_DEPTH),
1003 max_filter_primitives_per_filter: Some(DEFAULT_MAX_FILTER_PRIMITIVES_PER_FILTER),
1004 max_total_filter_primitives: Some(DEFAULT_MAX_TOTAL_FILTER_PRIMITIVES),
1005 max_subroots: Some(DEFAULT_MAX_SVG_SUBROOTS),
1006 max_nested_svg_images: Some(DEFAULT_MAX_NESTED_SVG_IMAGES),
1007 }
1008 }
1009
1010 pub const fn unbounded() -> Self {
1011 Self {
1012 max_isolation_depth: None,
1013 max_filter_primitives_per_filter: None,
1014 max_total_filter_primitives: None,
1015 max_subroots: None,
1016 max_nested_svg_images: None,
1017 }
1018 }
1019}
1020
1021#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1022impl Default for SvgConversionLimits {
1023 fn default() -> Self {
1024 Self::default_safe()
1025 }
1026}
1027
1028#[cfg(feature = "pdf")]
1030#[derive(Debug, Clone, Copy, PartialEq, Default)]
1031pub enum PdfPagePolicy {
1032 #[default]
1034 FitSvg,
1035 Fixed { width_pt: f32, height_pt: f32 },
1037 FitCssWidth { max_width_px: f32 },
1040}
1041
1042#[cfg(feature = "pdf")]
1044#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub struct PdfFilterImageLimit {
1046 pub max_total_pixels: Option<u64>,
1047}
1048
1049#[cfg(feature = "pdf")]
1050impl PdfFilterImageLimit {
1051 pub const fn new(max_total_pixels: Option<u64>) -> Self {
1052 Self { max_total_pixels }
1053 }
1054
1055 pub const fn default_safe() -> Self {
1056 Self {
1057 max_total_pixels: Some(DEFAULT_MAX_PDF_FILTER_IMAGE_PIXELS),
1058 }
1059 }
1060
1061 pub const fn unbounded() -> Self {
1062 Self {
1063 max_total_pixels: None,
1064 }
1065 }
1066}
1067
1068#[cfg(feature = "pdf")]
1069impl Default for PdfFilterImageLimit {
1070 fn default() -> Self {
1071 Self::default_safe()
1072 }
1073}
1074
1075#[cfg(feature = "pdf")]
1077#[derive(Debug, Clone, PartialEq)]
1078pub struct PdfOptions {
1079 pub page_policy: PdfPagePolicy,
1081 pub background: Option<String>,
1083 pub filter_scale: f32,
1085 pub filter_image_limit: PdfFilterImageLimit,
1087 pub embedded_image_limit: EmbeddedImageLimit,
1089 pub conversion_limits: SvgConversionLimits,
1091 #[cfg(feature = "native-fonts")]
1092 pub font_context: Option<NativeFontContext>,
1093}
1094
1095#[cfg(feature = "pdf")]
1096impl Default for PdfOptions {
1097 fn default() -> Self {
1098 Self {
1099 page_policy: PdfPagePolicy::FitSvg,
1100 background: None,
1101 filter_scale: 4.0,
1102 filter_image_limit: PdfFilterImageLimit::default(),
1103 embedded_image_limit: EmbeddedImageLimit::default(),
1104 conversion_limits: SvgConversionLimits::default(),
1105 #[cfg(feature = "native-fonts")]
1106 font_context: None,
1107 }
1108 }
1109}
1110
1111#[cfg(feature = "pdf")]
1112impl PdfOptions {
1113 #[cfg(feature = "native-fonts")]
1114 pub fn with_font_context(mut self, font_context: NativeFontContext) -> Self {
1115 self.font_context = Some(font_context);
1116 self
1117 }
1118
1119 pub fn with_page_policy(mut self, page_policy: PdfPagePolicy) -> Self {
1120 self.page_policy = page_policy;
1121 self
1122 }
1123
1124 pub fn with_background(mut self, background: impl Into<String>) -> Self {
1125 self.background = Some(background.into());
1126 self
1127 }
1128
1129 pub fn with_filter_scale(mut self, filter_scale: f32) -> Self {
1130 self.filter_scale = filter_scale;
1131 self
1132 }
1133
1134 pub fn with_filter_image_limit(mut self, filter_image_limit: PdfFilterImageLimit) -> Self {
1135 self.filter_image_limit = filter_image_limit;
1136 self
1137 }
1138
1139 pub fn with_unbounded_filter_images(mut self) -> Self {
1140 self.filter_image_limit = PdfFilterImageLimit::unbounded();
1141 self
1142 }
1143
1144 pub fn with_embedded_image_limit(mut self, embedded_image_limit: EmbeddedImageLimit) -> Self {
1145 self.embedded_image_limit = embedded_image_limit;
1146 self
1147 }
1148
1149 pub fn with_conversion_limits(mut self, conversion_limits: SvgConversionLimits) -> Self {
1150 self.conversion_limits = conversion_limits;
1151 self
1152 }
1153}
1154
1155#[cfg(any(feature = "png", feature = "jpeg"))]
1156impl Default for RasterOptions {
1157 fn default() -> Self {
1158 Self {
1159 scale: 1.0,
1160 background: None,
1161 jpeg_quality: 90,
1162 fit_to: None,
1163 size_limit: RasterSizeLimit::default(),
1164 embedded_image_limit: EmbeddedImageLimit::default(),
1165 conversion_limits: SvgConversionLimits::default(),
1166 #[cfg(feature = "native-fonts")]
1167 font_context: None,
1168 }
1169 }
1170}
1171
1172#[cfg(any(feature = "png", feature = "jpeg"))]
1173impl RasterOptions {
1174 #[cfg(feature = "native-fonts")]
1175 pub fn with_font_context(mut self, font_context: NativeFontContext) -> Self {
1176 self.font_context = Some(font_context);
1177 self
1178 }
1179
1180 pub fn with_scale(mut self, scale: f32) -> Self {
1181 self.scale = scale;
1182 self
1183 }
1184
1185 pub fn with_background(mut self, background: impl Into<String>) -> Self {
1186 self.background = Some(background.into());
1187 self
1188 }
1189
1190 pub fn with_fit_to(mut self, fit_to: RasterFitBox) -> Self {
1191 self.fit_to = Some(fit_to);
1192 self
1193 }
1194
1195 pub fn with_size_limit(mut self, size_limit: RasterSizeLimit) -> Self {
1196 self.size_limit = size_limit;
1197 self
1198 }
1199
1200 pub fn with_unbounded_size(mut self) -> Self {
1201 self.size_limit = RasterSizeLimit::unbounded();
1202 self
1203 }
1204
1205 pub fn with_embedded_image_limit(mut self, embedded_image_limit: EmbeddedImageLimit) -> Self {
1206 self.embedded_image_limit = embedded_image_limit;
1207 self
1208 }
1209
1210 pub fn with_conversion_limits(mut self, conversion_limits: SvgConversionLimits) -> Self {
1211 self.conversion_limits = conversion_limits;
1212 self
1213 }
1214}
1215
1216#[cfg(any(feature = "png", feature = "jpeg"))]
1217#[derive(Debug, Clone, Copy, PartialEq)]
1218pub struct RasterPlan {
1219 pub requested_width_px: f64,
1220 pub requested_height_px: f64,
1221 pub width_px: u32,
1222 pub height_px: u32,
1223 pub requested_scale: f64,
1224 pub effective_scale: f64,
1225 pub limited: bool,
1226}
1227
1228#[cfg(feature = "pdf")]
1230#[derive(Debug, Clone, Copy, PartialEq)]
1231pub struct PdfFilterImagePlan {
1232 pub filtered_groups: usize,
1233 pub requested_scale: f32,
1234 pub effective_scale: f32,
1235 pub requested_image_pixels: u64,
1236 pub effective_image_pixels: u64,
1237 pub limited: bool,
1238}
1239
1240#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1243pub struct EmbeddedImagePlan {
1244 pub data_resources: usize,
1245 pub raster_images: usize,
1246 pub largest_data_bytes: u64,
1247 pub total_data_bytes: u64,
1248 pub largest_raster_pixels: u64,
1249 pub total_pixels: u64,
1250}
1251
1252#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1254#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1255pub struct SvgConversionPlan {
1256 pub tree_nodes: usize,
1258 pub max_tree_depth: usize,
1260 pub max_isolation_depth: usize,
1261 pub filtered_groups: usize,
1262 pub filter_primitives: usize,
1263 pub subroots: usize,
1264 pub nested_svg_images: usize,
1265}
1266
1267#[cfg(any(feature = "png", feature = "jpeg"))]
1269pub struct PreparedRaster {
1270 tree: usvg::Tree,
1271 geometry: RasterGeometry,
1272 translate_min_to_origin: bool,
1273 plan: RasterPlan,
1274 embedded_image_plan: EmbeddedImagePlan,
1275 conversion_plan: SvgConversionPlan,
1276 options: RasterOptions,
1277 control: OperationControl,
1278}
1279
1280#[cfg(any(feature = "png", feature = "jpeg"))]
1281impl PreparedRaster {
1282 pub const fn plan(&self) -> RasterPlan {
1284 self.plan
1285 }
1286
1287 pub const fn embedded_image_plan(&self) -> EmbeddedImagePlan {
1289 self.embedded_image_plan
1290 }
1291
1292 pub const fn conversion_plan(&self) -> SvgConversionPlan {
1293 self.conversion_plan
1294 }
1295
1296 pub fn png_scheduling_weight_bytes(&self) -> u64 {
1300 encoding_scheduling_weight_bytes(self.plan, self.embedded_image_plan, 8)
1301 }
1302
1303 pub fn jpeg_scheduling_weight_bytes(&self) -> u64 {
1307 encoding_scheduling_weight_bytes(self.plan, self.embedded_image_plan, 10)
1308 }
1309
1310 #[cfg(feature = "png")]
1312 pub fn encode_png(self) -> Result<Vec<u8>> {
1313 let control = self.control.clone();
1314 #[cfg(feature = "native-fonts")]
1315 let font_context = self.options.font_context.clone();
1316 run_recursive_svg_backend(&control, move |control| {
1317 export_checkpoint(control)?;
1318 let pixmap = self.into_pixmap(control)?;
1319 export_checkpoint(control)?;
1320 let bytes = pixmap.encode_png().map_err(|_| ExportError::PngEncode);
1321 export_checkpoint(control)?;
1322 #[cfg(feature = "native-fonts")]
1323 if let Some(context) = &font_context {
1324 context.check()?;
1325 }
1326 bytes
1327 })
1328 }
1329
1330 #[cfg(feature = "jpeg")]
1332 pub fn encode_jpeg(mut self) -> Result<Vec<u8>> {
1333 let control = self.control.clone();
1334 #[cfg(feature = "native-fonts")]
1335 let font_context = self.options.font_context.clone();
1336 run_recursive_svg_backend(&control, move |control| {
1337 export_checkpoint(control)?;
1338 if self.plan.width_px > u32::from(u16::MAX) || self.plan.height_px > u32::from(u16::MAX)
1339 {
1340 return Err(ExportError::JpegDimensionLimit);
1341 }
1342 let bg = self.options.background.as_deref().unwrap_or("white");
1343 let Some(color) = parse_tiny_skia_color(bg) else {
1344 return Err(ExportError::JpegBackground);
1345 };
1346 if color.alpha() != 1.0 {
1347 return Err(ExportError::JpegOpaqueBackgroundRequired);
1348 }
1349
1350 self.options.background = Some(bg.to_string());
1351 let quality = self.options.jpeg_quality;
1352 let pixmap = self.into_pixmap(control)?;
1353 export_checkpoint(control)?;
1354 let (w, h) = (pixmap.width(), pixmap.height());
1355 let rgba = pixmap.data();
1356 let mut rgb = vec![0u8; (w as usize) * (h as usize) * 3];
1357 for (src, dst) in rgba.chunks_exact(4).zip(rgb.chunks_exact_mut(3)) {
1358 export_checkpoint(control)?;
1359 dst[0] = src[0];
1360 dst[1] = src[1];
1361 dst[2] = src[2];
1362 }
1363
1364 let mut out = Vec::new();
1365 let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality);
1366 export_checkpoint(control)?;
1367 let encoded = enc
1368 .encode(&rgb, w, h, image::ExtendedColorType::Rgb8)
1369 .map_err(|_| ExportError::JpegEncode);
1370 export_checkpoint(control)?;
1371 encoded?;
1372 #[cfg(feature = "native-fonts")]
1373 if let Some(context) = &font_context {
1374 context.check()?;
1375 }
1376 Ok(out)
1377 })
1378 }
1379
1380 fn into_pixmap(self, control: &OperationControl) -> Result<tiny_skia::Pixmap> {
1381 export_checkpoint(control)?;
1382 #[cfg(feature = "native-fonts")]
1383 if let Some(context) = &self.options.font_context {
1384 context.check()?;
1385 }
1386 let mut pixmap = tiny_skia::Pixmap::new(self.plan.width_px, self.plan.height_px)
1387 .ok_or(ExportError::PixmapAlloc)?;
1388
1389 if let Some(bg) = self.options.background.as_deref()
1390 && let Some(color) = parse_tiny_skia_color(bg)
1391 {
1392 pixmap.fill(color);
1393 }
1394
1395 let scale = self.plan.effective_scale as f32;
1396 let transform = if self.translate_min_to_origin {
1397 tiny_skia::Transform::from_row(
1398 scale,
1399 0.0,
1400 0.0,
1401 scale,
1402 -self.geometry.min_x * scale,
1403 -self.geometry.min_y * scale,
1404 )
1405 } else {
1406 tiny_skia::Transform::from_scale(scale, scale)
1407 };
1408
1409 export_checkpoint(control)?;
1412 resvg::render(&self.tree, transform, &mut pixmap.as_mut());
1413 export_checkpoint(control)?;
1414 #[cfg(feature = "native-fonts")]
1415 if let Some(context) = &self.options.font_context {
1416 context.check()?;
1417 }
1418 Ok(pixmap)
1419 }
1420}
1421
1422#[cfg(feature = "pdf")]
1424pub struct PreparedPdf {
1425 tree: usvg::Tree,
1426 options: PdfOptions,
1427 filter_plan: PdfFilterImagePlan,
1428 embedded_image_plan: EmbeddedImagePlan,
1429 conversion_plan: SvgConversionPlan,
1430 control: OperationControl,
1431}
1432
1433#[cfg(feature = "pdf")]
1434impl PreparedPdf {
1435 pub const fn filter_plan(&self) -> PdfFilterImagePlan {
1437 self.filter_plan
1438 }
1439
1440 pub const fn embedded_image_plan(&self) -> EmbeddedImagePlan {
1442 self.embedded_image_plan
1443 }
1444
1445 pub const fn conversion_plan(&self) -> SvgConversionPlan {
1446 self.conversion_plan
1447 }
1448
1449 pub fn scheduling_weight_bytes(&self) -> u64 {
1453 const PDF_ENCODER_OVERHEAD_BYTES: u64 = 1024 * 1024;
1454 self.filter_plan
1455 .effective_image_pixels
1456 .saturating_mul(8)
1457 .saturating_add(self.embedded_image_plan.total_pixels.saturating_mul(8))
1458 .saturating_add(PDF_ENCODER_OVERHEAD_BYTES)
1459 .saturating_add(RECURSIVE_SVG_BACKEND_STACK_BYTES as u64)
1460 }
1461
1462 pub fn encode(self) -> Result<Vec<u8>> {
1464 let control = self.control.clone();
1465 run_recursive_svg_backend(&control, move |control| {
1466 svg_tree_to_pdf(&self.tree, &self.options, control)
1467 })
1468 }
1469}
1470
1471#[cfg(any(feature = "png", feature = "jpeg"))]
1473pub fn prepare_raster(svg: &ResvgCompatibleSvg, options: &RasterOptions) -> Result<PreparedRaster> {
1474 prepare_raster_controlled(svg, options, OperationControl::new())
1475}
1476
1477#[cfg(any(feature = "png", feature = "jpeg"))]
1479pub fn prepare_raster_controlled(
1480 svg: &ResvgCompatibleSvg,
1481 options: &RasterOptions,
1482 control: OperationControl,
1483) -> Result<PreparedRaster> {
1484 export_checkpoint(&control)?;
1485 let source = svg.as_str().to_owned();
1486 let reference_plan = svg.reference_plan().clone();
1487 let options = options.clone();
1488 let backend_control = control.clone();
1489 run_recursive_svg_backend(&control, move |control| {
1490 prepare_raster_on_backend_stack(
1491 &source,
1492 reference_plan.raw_element_occurrences(),
1493 &options,
1494 control,
1495 )
1496 .map(|mut prepared| {
1497 prepared.control = backend_control;
1498 prepared
1499 })
1500 })
1501}
1502
1503#[cfg(any(feature = "png", feature = "jpeg"))]
1504fn prepare_raster_on_backend_stack(
1505 source: &str,
1506 raw_element_occurrences: &[usize],
1507 options: &RasterOptions,
1508 control: &OperationControl,
1509) -> Result<PreparedRaster> {
1510 export_checkpoint(control)?;
1511 let root_metadata = parse_root_svg_metadata(source, control)?;
1512 let mut usvg_options = usvg::Options::default();
1513 configure_usvg_options_for_raster(&mut usvg_options, root_metadata, options, control)?;
1514 let data_plan = plan_embedded_data_resources_with_occurrences(
1515 source,
1516 raw_element_occurrences,
1517 options.embedded_image_limit,
1518 control,
1519 )?;
1520 export_checkpoint(control)?;
1521 let tree = usvg::Tree::from_str(source, &usvg_options).map_err(|_| ExportError::SvgParse);
1522 export_checkpoint(control)?;
1523 #[cfg(feature = "native-fonts")]
1524 if let Some(context) = &options.font_context {
1525 context.check()?;
1526 }
1527 let tree = tree?;
1528 let conversion_plan = plan_svg_conversion(&tree, options.conversion_limits, control)?;
1529 let embedded_image_plan =
1530 plan_embedded_images(&tree, options.embedded_image_limit, data_plan, control)?;
1531 let (geometry, translate_min_to_origin) = raster_geometry_for_svg(root_metadata, &tree);
1532 let plan = raster_plan_for_geometry(geometry, options, control)?;
1533 export_checkpoint(control)?;
1534
1535 Ok(PreparedRaster {
1536 tree,
1537 geometry,
1538 translate_min_to_origin,
1539 plan,
1540 embedded_image_plan,
1541 conversion_plan,
1542 options: options.clone(),
1543 control: control.clone(),
1544 })
1545}
1546
1547#[cfg(feature = "pdf")]
1549pub fn prepare_pdf(svg: &ResvgCompatibleSvg, options: &PdfOptions) -> Result<PreparedPdf> {
1550 prepare_pdf_controlled(svg, options, OperationControl::new())
1551}
1552
1553#[cfg(feature = "pdf")]
1555pub fn prepare_pdf_controlled(
1556 svg: &ResvgCompatibleSvg,
1557 options: &PdfOptions,
1558 control: OperationControl,
1559) -> Result<PreparedPdf> {
1560 export_checkpoint(&control)?;
1561 let source = svg.as_str().to_owned();
1562 let reference_plan = svg.reference_plan().clone();
1563 let options = options.clone();
1564 let backend_control = control.clone();
1565 run_recursive_svg_backend(&control, move |control| {
1566 prepare_pdf_on_backend_stack(
1567 &source,
1568 reference_plan.raw_element_occurrences(),
1569 &options,
1570 control,
1571 )
1572 .map(|mut prepared| {
1573 prepared.control = backend_control;
1574 prepared
1575 })
1576 })
1577}
1578
1579#[cfg(feature = "pdf")]
1580fn prepare_pdf_on_backend_stack(
1581 source: &str,
1582 raw_element_occurrences: &[usize],
1583 options: &PdfOptions,
1584 control: &OperationControl,
1585) -> Result<PreparedPdf> {
1586 export_checkpoint(control)?;
1587 validate_pdf_options(options)?;
1588 let data_plan = plan_embedded_data_resources_with_occurrences(
1589 source,
1590 raw_element_occurrences,
1591 options.embedded_image_limit,
1592 control,
1593 )?;
1594 let tree = parse_pdf_tree(source, options, control)?;
1595 let conversion_plan = plan_svg_conversion(&tree, options.conversion_limits, control)?;
1596 let embedded_image_plan =
1597 plan_embedded_images(&tree, options.embedded_image_limit, data_plan, control)?;
1598 let svg_size = pdf_svg_size(&tree)?;
1599 let layout = pdf_page_layout(svg_size, options.page_policy)?;
1600 let filter_plan = plan_pdf_filter_images(
1601 &tree,
1602 layout.drawing_size.width() / svg_size.width(),
1603 options.filter_scale,
1604 options.filter_image_limit,
1605 control,
1606 )?;
1607 let mut effective_options = options.clone();
1608 effective_options.filter_scale = filter_plan.effective_scale;
1609
1610 Ok(PreparedPdf {
1611 tree,
1612 options: effective_options,
1613 filter_plan,
1614 embedded_image_plan,
1615 conversion_plan,
1616 control: control.clone(),
1617 })
1618}
1619
1620#[cfg(feature = "pdf")]
1621fn validate_pdf_options(options: &PdfOptions) -> Result<()> {
1622 if !(options.filter_scale.is_finite() && options.filter_scale > 0.0) {
1623 return Err(ExportError::InvalidSizing(
1624 "PDF filter_scale must be finite and positive",
1625 ));
1626 }
1627 if options.filter_image_limit.max_total_pixels == Some(0) {
1628 return Err(ExportError::InvalidSizing(
1629 "PDF filter max_total_pixels must be positive",
1630 ));
1631 }
1632 validate_embedded_image_limit(options.embedded_image_limit)?;
1633 Ok(())
1634}
1635
1636#[cfg(any(feature = "png", feature = "jpeg"))]
1637fn encoding_scheduling_weight_bytes(
1638 plan: RasterPlan,
1639 embedded_images: EmbeddedImagePlan,
1640 bytes_per_pixel: u64,
1641) -> u64 {
1642 const ENCODER_OVERHEAD_BYTES: u64 = 1024 * 1024;
1643 u64::from(plan.width_px)
1644 .saturating_mul(u64::from(plan.height_px))
1645 .saturating_mul(bytes_per_pixel)
1646 .saturating_add(embedded_images.total_pixels.saturating_mul(8))
1647 .saturating_add(ENCODER_OVERHEAD_BYTES)
1648 .saturating_add(RECURSIVE_SVG_BACKEND_STACK_BYTES as u64)
1649}
1650
1651#[cfg(feature = "png")]
1652pub fn svg_to_png(svg: &ResvgCompatibleSvg, options: &RasterOptions) -> Result<Vec<u8>> {
1653 svg_to_png_controlled(svg, options, OperationControl::new())
1654}
1655
1656#[cfg(feature = "png")]
1658pub fn svg_to_png_controlled(
1659 svg: &ResvgCompatibleSvg,
1660 options: &RasterOptions,
1661 control: OperationControl,
1662) -> Result<Vec<u8>> {
1663 prepare_raster_controlled(svg, options, control)?.encode_png()
1664}
1665
1666#[cfg(feature = "png")]
1668pub fn svg_to_png_with_plan_controlled(
1669 svg: &ResvgCompatibleSvg,
1670 options: &RasterOptions,
1671 control: OperationControl,
1672) -> Result<(Vec<u8>, RasterPlan)> {
1673 let prepared = prepare_raster_controlled(svg, options, control)?;
1674 let plan = prepared.plan();
1675 let bytes = prepared.encode_png()?;
1676 Ok((bytes, plan))
1677}
1678
1679#[cfg(feature = "png")]
1681pub fn svg_to_png_with_plan(
1682 svg: &ResvgCompatibleSvg,
1683 options: &RasterOptions,
1684) -> Result<(Vec<u8>, RasterPlan)> {
1685 svg_to_png_with_plan_controlled(svg, options, OperationControl::new())
1686}
1687
1688#[cfg(feature = "jpeg")]
1689pub fn svg_to_jpeg(svg: &ResvgCompatibleSvg, options: &RasterOptions) -> Result<Vec<u8>> {
1690 svg_to_jpeg_controlled(svg, options, OperationControl::new())
1691}
1692
1693#[cfg(feature = "jpeg")]
1695pub fn svg_to_jpeg_controlled(
1696 svg: &ResvgCompatibleSvg,
1697 options: &RasterOptions,
1698 control: OperationControl,
1699) -> Result<Vec<u8>> {
1700 prepare_raster_controlled(svg, options, control)?.encode_jpeg()
1701}
1702
1703#[cfg(feature = "jpeg")]
1705pub fn svg_to_jpeg_with_plan_controlled(
1706 svg: &ResvgCompatibleSvg,
1707 options: &RasterOptions,
1708 control: OperationControl,
1709) -> Result<(Vec<u8>, RasterPlan)> {
1710 let prepared = prepare_raster_controlled(svg, options, control)?;
1711 let plan = prepared.plan();
1712 let bytes = prepared.encode_jpeg()?;
1713 Ok((bytes, plan))
1714}
1715
1716#[cfg(feature = "jpeg")]
1718pub fn svg_to_jpeg_with_plan(
1719 svg: &ResvgCompatibleSvg,
1720 options: &RasterOptions,
1721) -> Result<(Vec<u8>, RasterPlan)> {
1722 svg_to_jpeg_with_plan_controlled(svg, options, OperationControl::new())
1723}
1724
1725#[cfg(any(feature = "png", feature = "jpeg"))]
1726pub fn svg_raster_plan(svg: &ResvgCompatibleSvg, options: &RasterOptions) -> Result<RasterPlan> {
1727 svg_raster_plan_controlled(svg, options, OperationControl::new())
1728}
1729
1730#[cfg(any(feature = "png", feature = "jpeg"))]
1732pub fn svg_raster_plan_controlled(
1733 svg: &ResvgCompatibleSvg,
1734 options: &RasterOptions,
1735 control: OperationControl,
1736) -> Result<RasterPlan> {
1737 Ok(prepare_raster_controlled(svg, options, control)?.plan())
1738}
1739
1740#[cfg(feature = "pdf")]
1741pub fn svg_to_pdf(svg: &ResvgCompatibleSvg) -> Result<Vec<u8>> {
1742 svg_to_pdf_controlled(svg, &PdfOptions::default(), OperationControl::new())
1743}
1744
1745#[cfg(feature = "pdf")]
1746pub fn svg_to_pdf_with_options(svg: &ResvgCompatibleSvg, options: &PdfOptions) -> Result<Vec<u8>> {
1747 svg_to_pdf_controlled(svg, options, OperationControl::new())
1748}
1749
1750#[cfg(feature = "pdf")]
1752pub fn svg_to_pdf_controlled(
1753 svg: &ResvgCompatibleSvg,
1754 options: &PdfOptions,
1755 control: OperationControl,
1756) -> Result<Vec<u8>> {
1757 prepare_pdf_controlled(svg, options, control)?.encode()
1758}
1759
1760#[cfg(feature = "pdf")]
1762pub fn svg_to_pdf_with_plan_controlled(
1763 svg: &ResvgCompatibleSvg,
1764 options: &PdfOptions,
1765 control: OperationControl,
1766) -> Result<(Vec<u8>, PdfFilterImagePlan)> {
1767 let prepared = prepare_pdf_controlled(svg, options, control)?;
1768 let plan = prepared.filter_plan();
1769 let bytes = prepared.encode()?;
1770 Ok((bytes, plan))
1771}
1772
1773#[cfg(feature = "pdf")]
1775pub fn svg_to_pdf_with_plan(
1776 svg: &ResvgCompatibleSvg,
1777 options: &PdfOptions,
1778) -> Result<(Vec<u8>, PdfFilterImagePlan)> {
1779 svg_to_pdf_with_plan_controlled(svg, options, OperationControl::new())
1780}
1781
1782#[cfg(feature = "pdf")]
1783fn parse_pdf_tree(
1784 svg: &str,
1785 options: &PdfOptions,
1786 control: &OperationControl,
1787) -> Result<usvg::Tree> {
1788 export_checkpoint(control)?;
1789 let mut opts = usvg::Options::default();
1790 configure_usvg_options_for_pdf(&mut opts, options, control)?;
1791 let tree = usvg::Tree::from_str(svg, &opts).map_err(|_| ExportError::SvgParse);
1792 export_checkpoint(control)?;
1793 #[cfg(feature = "native-fonts")]
1794 if let Some(context) = &options.font_context {
1795 context.check()?;
1796 }
1797 tree
1798}
1799
1800#[cfg(feature = "pdf")]
1801fn svg_tree_to_pdf(
1802 svg_tree: &usvg::Tree,
1803 options: &PdfOptions,
1804 control: &OperationControl,
1805) -> Result<Vec<u8>> {
1806 use krilla_svg::SurfaceExt;
1807
1808 export_checkpoint(control)?;
1809 #[cfg(feature = "native-fonts")]
1810 if let Some(context) = &options.font_context {
1811 context.check()?;
1812 }
1813 let svg_size = pdf_svg_size(svg_tree)?;
1814 let layout = pdf_page_layout(svg_size, options.page_policy)?;
1815
1816 let mut document = krilla::Document::new();
1817 let mut page = document.start_page_with(krilla::page::PageSettings::new(layout.page_size));
1818 let mut surface = page.surface();
1819 draw_pdf_background(
1820 &mut surface,
1821 layout.page_size,
1822 options.background.as_deref(),
1823 );
1824 if layout.offset.0 != 0.0 || layout.offset.1 != 0.0 {
1825 surface.push_transform(&krilla::geom::Transform::from_translate(
1826 layout.offset.0,
1827 layout.offset.1,
1828 ));
1829 }
1830 export_checkpoint(control)?;
1833 surface.draw_svg(
1834 svg_tree,
1835 layout.drawing_size,
1836 krilla_svg::SvgSettings {
1837 filter_scale: options.filter_scale,
1838 ..krilla_svg::SvgSettings::default()
1839 },
1840 );
1841 export_checkpoint(control)?;
1842 if layout.offset.0 != 0.0 || layout.offset.1 != 0.0 {
1843 surface.pop();
1844 }
1845 surface.finish();
1846 page.finish();
1847
1848 export_checkpoint(control)?;
1849 let pdf = document.finish().map_err(|_| ExportError::PdfConvert);
1850 #[cfg(feature = "native-fonts")]
1851 if let Some(context) = &options.font_context {
1852 context.check()?;
1853 }
1854 export_checkpoint(control)?;
1855 pdf
1856}
1857
1858#[cfg(feature = "pdf")]
1859fn pdf_svg_size(svg_tree: &usvg::Tree) -> Result<krilla::geom::Size> {
1860 krilla::geom::Size::from_wh(svg_tree.size().width(), svg_tree.size().height())
1861 .ok_or(ExportError::SvgDocSize)
1862}
1863
1864#[cfg(feature = "pdf")]
1865#[derive(Clone, Copy)]
1866struct PdfPageLayout {
1867 page_size: krilla::geom::Size,
1868 drawing_size: krilla::geom::Size,
1869 offset: (f32, f32),
1870}
1871
1872#[cfg(feature = "pdf")]
1873fn pdf_page_layout(
1874 svg_size: krilla::geom::Size,
1875 page_policy: PdfPagePolicy,
1876) -> Result<PdfPageLayout> {
1877 match page_policy {
1878 PdfPagePolicy::FitSvg => Ok(PdfPageLayout {
1879 page_size: svg_size,
1880 drawing_size: svg_size,
1881 offset: (0.0, 0.0),
1882 }),
1883 PdfPagePolicy::Fixed {
1884 width_pt,
1885 height_pt,
1886 } => {
1887 let Some(page_size) = krilla::geom::Size::from_wh(width_pt, height_pt) else {
1888 return Err(ExportError::InvalidSizing(
1889 "fixed PDF page dimensions must be finite and positive",
1890 ));
1891 };
1892 let scale = (width_pt / svg_size.width()).min(height_pt / svg_size.height());
1893 let Some(drawing_size) =
1894 krilla::geom::Size::from_wh(svg_size.width() * scale, svg_size.height() * scale)
1895 else {
1896 return Err(ExportError::SvgDocSize);
1897 };
1898 let offset = (
1899 (width_pt - drawing_size.width()) / 2.0,
1900 (height_pt - drawing_size.height()) / 2.0,
1901 );
1902 Ok(PdfPageLayout {
1903 page_size,
1904 drawing_size,
1905 offset,
1906 })
1907 }
1908 PdfPagePolicy::FitCssWidth { max_width_px } => {
1909 if !(max_width_px.is_finite() && max_width_px > 0.0) {
1910 return Err(ExportError::InvalidSizing(
1911 "PDF CSS viewport width must be finite and positive",
1912 ));
1913 }
1914 let displayed_width_px = svg_size.width().min(max_width_px);
1915 let page_width_pt = displayed_width_px * PDF_POINTS_PER_CSS_PIXEL;
1916 let page_height_pt = svg_size.height() / svg_size.width() * page_width_pt;
1917 let Some(page_size) = krilla::geom::Size::from_wh(page_width_pt, page_height_pt) else {
1918 return Err(ExportError::SvgDocSize);
1919 };
1920 Ok(PdfPageLayout {
1921 page_size,
1922 drawing_size: page_size,
1923 offset: (0.0, 0.0),
1924 })
1925 }
1926 }
1927}
1928
1929#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1930fn plan_svg_conversion(
1931 tree: &usvg::Tree,
1932 limits: SvgConversionLimits,
1933 control: &OperationControl,
1934) -> Result<SvgConversionPlan> {
1935 let mut plan = SvgConversionPlan::default();
1936 plan_svg_conversion_group(tree.root(), 0, 0, limits, &mut plan, control)?;
1937 Ok(plan)
1938}
1939
1940#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
1941fn plan_svg_conversion_group(
1942 root: &usvg::Group,
1943 parent_isolation_depth: usize,
1944 tree_depth: usize,
1945 limits: SvgConversionLimits,
1946 plan: &mut SvgConversionPlan,
1947 control: &OperationControl,
1948) -> Result<()> {
1949 let mut stack = vec![(root, parent_isolation_depth, tree_depth)];
1950 while let Some((group, parent_depth, tree_depth)) = stack.pop() {
1951 export_checkpoint(control)?;
1952 charge_svg_conversion_tree_node(plan)?;
1953 plan.max_tree_depth = plan.max_tree_depth.max(tree_depth);
1954 check_svg_conversion_limit(
1955 merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID,
1956 plan.max_tree_depth,
1957 Some(merman_render::resources::MAX_RESVG_TREE_DEPTH),
1958 )?;
1959
1960 let isolation_depth = parent_depth.saturating_add(usize::from(group.should_isolate()));
1961 plan.max_isolation_depth = plan.max_isolation_depth.max(isolation_depth);
1962 check_svg_conversion_limit(
1963 "max_isolation_depth",
1964 plan.max_isolation_depth,
1965 limits.max_isolation_depth,
1966 )?;
1967
1968 if !group.filters().is_empty() {
1969 plan.filtered_groups = plan.filtered_groups.saturating_add(1);
1970 }
1971 for filter in group.filters() {
1972 export_checkpoint(control)?;
1973 let primitives = filter.primitives().len();
1974 check_svg_conversion_limit(
1975 "max_filter_primitives_per_filter",
1976 primitives,
1977 limits.max_filter_primitives_per_filter,
1978 )?;
1979 plan.filter_primitives = plan.filter_primitives.saturating_add(primitives);
1980 check_svg_conversion_limit(
1981 "max_total_filter_primitives",
1982 plan.filter_primitives,
1983 limits.max_total_filter_primitives,
1984 )?;
1985 }
1986
1987 for node in group.children() {
1988 export_checkpoint(control)?;
1989 if let usvg::Node::Group(child) = node {
1990 stack.push((child, isolation_depth, tree_depth.saturating_add(1)));
1991 } else {
1992 charge_svg_conversion_tree_node(plan)?;
1993 }
1994 if let usvg::Node::Image(image) = node
1995 && matches!(image.kind(), usvg::ImageKind::SVG(_))
1996 {
1997 plan.nested_svg_images = plan.nested_svg_images.saturating_add(1);
1998 check_svg_conversion_limit(
1999 "max_nested_svg_images",
2000 plan.nested_svg_images,
2001 limits.max_nested_svg_images,
2002 )?;
2003 }
2004
2005 let mut subroot_result = Ok(());
2006 node.subroots(|subroot| {
2007 if subroot_result.is_err() {
2008 return;
2009 }
2010 plan.subroots = plan.subroots.saturating_add(1);
2011 subroot_result =
2012 check_svg_conversion_limit("max_subroots", plan.subroots, limits.max_subroots);
2013 if subroot_result.is_ok() {
2014 subroot_result = plan_svg_conversion_group(
2015 subroot,
2016 isolation_depth.saturating_add(1),
2017 tree_depth.saturating_add(1),
2018 limits,
2019 plan,
2020 control,
2021 );
2022 }
2023 });
2024 subroot_result?;
2025 }
2026 }
2027 Ok(())
2028}
2029
2030#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2031fn charge_svg_conversion_tree_node(plan: &mut SvgConversionPlan) -> Result<()> {
2032 plan.tree_nodes = plan.tree_nodes.saturating_add(1);
2033 check_svg_conversion_limit(
2034 merman_render::resources::SVG_BACKEND_TREE_NODES_HARD_CAP_ID,
2035 plan.tree_nodes,
2036 Some(merman_render::resources::MAX_RESVG_TREE_NODES),
2037 )
2038}
2039
2040#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2041fn check_svg_conversion_limit(
2042 limit_name: &'static str,
2043 actual: usize,
2044 max: Option<usize>,
2045) -> Result<()> {
2046 let Some(max) = max else {
2047 return Ok(());
2048 };
2049 if actual <= max {
2050 return Ok(());
2051 }
2052 Err(ExportError::SvgConversionLimit {
2053 limit_name,
2054 actual: actual as u64,
2055 max: max as u64,
2056 })
2057}
2058
2059#[cfg(feature = "pdf")]
2060fn plan_pdf_filter_images(
2061 tree: &usvg::Tree,
2062 page_scale: f32,
2063 requested_scale: f32,
2064 limit: PdfFilterImageLimit,
2065 control: &OperationControl,
2066) -> Result<PdfFilterImagePlan> {
2067 let filtered_groups = pdf_filtered_group_bounds(tree, control)?;
2068 let requested_pixels =
2069 pdf_filter_pixels(&filtered_groups, page_scale, requested_scale, control)?;
2070 let Some(max_pixels) = limit.max_total_pixels else {
2071 return Ok(PdfFilterImagePlan {
2072 filtered_groups: filtered_groups.len(),
2073 requested_scale,
2074 effective_scale: requested_scale,
2075 requested_image_pixels: requested_pixels,
2076 effective_image_pixels: requested_pixels,
2077 limited: false,
2078 });
2079 };
2080 if requested_pixels <= max_pixels {
2081 return Ok(PdfFilterImagePlan {
2082 filtered_groups: filtered_groups.len(),
2083 requested_scale,
2084 effective_scale: requested_scale,
2085 requested_image_pixels: requested_pixels,
2086 effective_image_pixels: requested_pixels,
2087 limited: false,
2088 });
2089 }
2090
2091 let mut accepted = 0.0_f32;
2092 let mut rejected = requested_scale;
2093 for _ in 0..48 {
2094 let candidate = accepted + (rejected - accepted) / 2.0;
2095 export_checkpoint(control)?;
2096 if pdf_filter_pixels(&filtered_groups, page_scale, candidate, control)? <= max_pixels {
2097 accepted = candidate;
2098 } else {
2099 rejected = candidate;
2100 }
2101 }
2102 if !(accepted.is_finite() && accepted > 0.0) {
2103 return Err(ExportError::PdfFilterImageLimit {
2104 actual: requested_pixels,
2105 max: max_pixels,
2106 });
2107 }
2108 let effective_pixels = pdf_filter_pixels(&filtered_groups, page_scale, accepted, control)?;
2109 Ok(PdfFilterImagePlan {
2110 filtered_groups: filtered_groups.len(),
2111 requested_scale,
2112 effective_scale: accepted,
2113 requested_image_pixels: requested_pixels,
2114 effective_image_pixels: effective_pixels,
2115 limited: true,
2116 })
2117}
2118
2119#[cfg(feature = "pdf")]
2120fn pdf_filtered_group_bounds(
2121 tree: &usvg::Tree,
2122 control: &OperationControl,
2123) -> Result<Vec<(f64, f64)>> {
2124 let mut bounds = Vec::new();
2125 let mut stack = vec![(tree.root(), 1.0_f64)];
2126 while let Some((group, coordinate_scale)) = stack.pop() {
2127 export_checkpoint(control)?;
2128 if !group.filters().is_empty() {
2129 let bbox = group.abs_layer_bounding_box();
2130 bounds.push((
2131 f64::from(bbox.width()) * coordinate_scale,
2132 f64::from(bbox.height()) * coordinate_scale,
2133 ));
2134 continue;
2138 }
2139 for node in group.children() {
2140 export_checkpoint(control)?;
2141 match node {
2142 usvg::Node::Group(child) => stack.push((child, coordinate_scale)),
2143 usvg::Node::Image(image) => {
2144 if let usvg::ImageKind::SVG(nested) = image.kind() {
2145 let (scale_x, scale_y) = image.abs_transform().get_scale();
2146 let image_scale = f64::from(scale_x.abs().max(scale_y.abs()));
2147 stack.push((nested.root(), coordinate_scale * image_scale));
2148 }
2149 }
2150 _ => {}
2151 }
2152 }
2153 }
2154 Ok(bounds)
2155}
2156
2157#[cfg(feature = "pdf")]
2158fn pdf_filter_pixels(
2159 bounds: &[(f64, f64)],
2160 page_scale: f32,
2161 filter_scale: f32,
2162 control: &OperationControl,
2163) -> Result<u64> {
2164 let scale = f64::from(page_scale) * f64::from(filter_scale);
2165 let mut total = 0_u64;
2166 for &(width, height) in bounds {
2167 export_checkpoint(control)?;
2168 let requested_width = width * scale;
2169 let requested_height = height * scale;
2170 let cap = (KRILLA_MAX_FILTER_SIDE_PX / requested_width)
2171 .min(KRILLA_MAX_FILTER_SIDE_PX / requested_height)
2172 .min(1.0);
2173 let width_px = (requested_width * cap).round().clamp(0.0, 5000.0) as u64;
2174 let height_px = (requested_height * cap).round().clamp(0.0, 5000.0) as u64;
2175 total = total.saturating_add(width_px.saturating_mul(height_px));
2176 }
2177 Ok(total)
2178}
2179
2180#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2181#[derive(Debug, Clone, Copy, Default)]
2182struct EmbeddedDataPlan {
2183 resources: usize,
2184 largest_bytes: u64,
2185 total_bytes: u64,
2186}
2187
2188#[cfg(all(test, any(feature = "png", feature = "jpeg", feature = "pdf")))]
2189fn plan_embedded_data_resources(svg: &str, limit: EmbeddedImageLimit) -> Result<EmbeddedDataPlan> {
2190 plan_embedded_data_resources_with_occurrences(svg, &[], limit, &OperationControl::new())
2191}
2192
2193#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2194fn plan_embedded_data_resources_with_occurrences(
2195 svg: &str,
2196 raw_element_occurrences: &[usize],
2197 limit: EmbeddedImageLimit,
2198 control: &OperationControl,
2199) -> Result<EmbeddedDataPlan> {
2200 use quick_xml::XmlVersion;
2201 use quick_xml::events::Event;
2202
2203 validate_embedded_image_limit(limit)?;
2204 let mut reader = quick_xml::Reader::from_str(svg);
2205 let mut plan = EmbeddedDataPlan::default();
2206 let mut element_index = 0usize;
2207 loop {
2208 export_checkpoint(control)?;
2209 let event = reader.read_event().map_err(|_| ExportError::SvgParse)?;
2210 let (element, occurrences) = match event {
2211 Event::Start(element) | Event::Empty(element) => {
2212 let occurrences = if raw_element_occurrences.is_empty() {
2213 1
2214 } else {
2215 raw_element_occurrences
2216 .get(element_index)
2217 .copied()
2218 .ok_or(ExportError::SvgParse)?
2219 };
2220 element_index = element_index.saturating_add(1);
2221 if is_embedded_image_element(element.local_name().as_ref()) {
2222 (element, occurrences)
2223 } else {
2224 continue;
2225 }
2226 }
2227 Event::Eof => break,
2228 _ => continue,
2229 };
2230
2231 for attribute in element.attributes() {
2232 export_checkpoint(control)?;
2233 let attribute = attribute.map_err(|_| ExportError::SvgParse)?;
2234 if !attribute
2235 .key
2236 .local_name()
2237 .as_ref()
2238 .eq_ignore_ascii_case("href")
2239 {
2240 continue;
2241 }
2242 let value = attribute
2243 .normalized_value(XmlVersion::Implicit1_0)
2244 .map_err(|_| ExportError::SvgParse)?;
2245 let Ok(data_url) = data_url::DataUrl::process(value.as_ref()) else {
2246 continue;
2247 };
2248
2249 let mut resource_bytes = 0_u64;
2250 let mut limit_error = None;
2251 let occurrences = occurrences as u64;
2252 let _ = data_url.decode(|chunk| {
2253 if let Err(error) = export_checkpoint(control) {
2254 limit_error = Some(error);
2255 return Err(());
2256 }
2257 resource_bytes = resource_bytes.saturating_add(chunk.len() as u64);
2258 let aggregate = plan
2259 .total_bytes
2260 .saturating_add(resource_bytes.saturating_mul(occurrences));
2261 if let Some(max) = limit.max_bytes_per_image
2262 && resource_bytes > max
2263 {
2264 limit_error = Some(ExportError::EmbeddedImageLimit {
2265 limit_name: "max_bytes_per_image",
2266 actual: resource_bytes,
2267 max,
2268 });
2269 return Err(());
2270 }
2271 if let Some(max) = limit.max_total_bytes
2272 && aggregate > max
2273 {
2274 limit_error = Some(ExportError::EmbeddedImageLimit {
2275 limit_name: "max_total_bytes",
2276 actual: aggregate,
2277 max,
2278 });
2279 return Err(());
2280 }
2281 Ok(())
2282 });
2283 if let Some(error) = limit_error {
2284 return Err(error);
2285 }
2286
2287 plan.resources = plan.resources.saturating_add(occurrences as usize);
2288 plan.largest_bytes = plan.largest_bytes.max(resource_bytes);
2289 plan.total_bytes = plan
2290 .total_bytes
2291 .saturating_add(resource_bytes.saturating_mul(occurrences));
2292 }
2293 }
2294 if !raw_element_occurrences.is_empty() && element_index != raw_element_occurrences.len() {
2295 return Err(ExportError::SvgParse);
2296 }
2297 Ok(plan)
2298}
2299
2300#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2301fn is_embedded_image_element(local_name: &str) -> bool {
2302 local_name.eq_ignore_ascii_case("image") || local_name.eq_ignore_ascii_case("feImage")
2305}
2306
2307#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2308fn plan_embedded_images(
2309 tree: &usvg::Tree,
2310 limit: EmbeddedImageLimit,
2311 data: EmbeddedDataPlan,
2312 control: &OperationControl,
2313) -> Result<EmbeddedImagePlan> {
2314 validate_embedded_image_limit(limit)?;
2315 let mut plan = EmbeddedImagePlan {
2316 data_resources: data.resources,
2317 raster_images: 0,
2318 largest_data_bytes: data.largest_bytes,
2319 total_data_bytes: data.total_bytes,
2320 largest_raster_pixels: 0,
2321 total_pixels: 0,
2322 };
2323 plan_embedded_images_in_group(tree.root(), limit, &mut plan, control)?;
2324 Ok(plan)
2325}
2326
2327#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2328fn plan_embedded_images_in_group(
2329 root: &usvg::Group,
2330 limit: EmbeddedImageLimit,
2331 plan: &mut EmbeddedImagePlan,
2332 control: &OperationControl,
2333) -> Result<()> {
2334 let mut stack = vec![root];
2335 while let Some(group) = stack.pop() {
2336 export_checkpoint(control)?;
2337 for node in group.children() {
2338 export_checkpoint(control)?;
2339 match node {
2340 usvg::Node::Group(child) => stack.push(child),
2341 usvg::Node::Image(image) => match image.kind() {
2342 usvg::ImageKind::SVG(_) => {}
2343 usvg::ImageKind::JPEG(_)
2344 | usvg::ImageKind::PNG(_)
2345 | usvg::ImageKind::GIF(_)
2346 | usvg::ImageKind::WEBP(_) => {
2347 let pixels = intrinsic_image_pixels(image.size())?;
2348 plan.raster_images = plan.raster_images.saturating_add(1);
2349 plan.largest_raster_pixels = plan.largest_raster_pixels.max(pixels);
2350 plan.total_pixels = plan.total_pixels.saturating_add(pixels);
2351 if let Some(max) = limit.max_pixels_per_image
2352 && pixels > max
2353 {
2354 return Err(ExportError::EmbeddedImageLimit {
2355 limit_name: "max_pixels_per_image",
2356 actual: pixels,
2357 max,
2358 });
2359 }
2360 if let Some(max) = limit.max_total_pixels
2361 && plan.total_pixels > max
2362 {
2363 return Err(ExportError::EmbeddedImageLimit {
2364 limit_name: "max_total_pixels",
2365 actual: plan.total_pixels,
2366 max,
2367 });
2368 }
2369 }
2370 },
2371 _ => {}
2372 }
2373
2374 let mut subroot_result = Ok(());
2378 node.subroots(|subroot| {
2379 if subroot_result.is_ok() {
2380 subroot_result = plan_embedded_images_in_group(subroot, limit, plan, control);
2381 }
2382 });
2383 subroot_result?;
2384 }
2385 }
2386 Ok(())
2387}
2388
2389#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2390fn intrinsic_image_pixels(size: usvg::Size) -> Result<u64> {
2391 let width = f64::from(size.width()).ceil();
2392 let height = f64::from(size.height()).ceil();
2393 if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
2394 return Err(ExportError::InvalidSizing(
2395 "embedded image dimensions must be finite and positive",
2396 ));
2397 }
2398 if width > u64::MAX as f64 || height > u64::MAX as f64 {
2399 return Err(ExportError::InvalidSizing(
2400 "embedded image dimensions exceed the planner capability",
2401 ));
2402 }
2403 Ok((width as u64).saturating_mul(height as u64))
2404}
2405
2406#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2407fn validate_embedded_image_limit(limit: EmbeddedImageLimit) -> Result<()> {
2408 if limit.max_bytes_per_image == Some(0)
2409 || limit.max_total_bytes == Some(0)
2410 || limit.max_pixels_per_image == Some(0)
2411 || limit.max_total_pixels == Some(0)
2412 {
2413 return Err(ExportError::InvalidSizing(
2414 "embedded image byte and pixel limits must be positive",
2415 ));
2416 }
2417 Ok(())
2418}
2419
2420#[cfg(feature = "pdf")]
2421fn draw_pdf_background(
2422 surface: &mut krilla::surface::Surface<'_>,
2423 page_size: krilla::geom::Size,
2424 background: Option<&str>,
2425) {
2426 let Some(background) = background.filter(|value| !value.eq_ignore_ascii_case("transparent"))
2427 else {
2428 return;
2429 };
2430 let Some(color) = parse_rgba_color(background) else {
2431 return;
2432 };
2433 let Some(opacity) = krilla::num::NormalizedF32::new(f32::from(color.alpha) / 255.0) else {
2434 return;
2435 };
2436 let mut path = krilla::geom::PathBuilder::new();
2437 let Some(rect) = krilla::geom::Rect::from_xywh(0.0, 0.0, page_size.width(), page_size.height())
2438 else {
2439 return;
2440 };
2441 path.push_rect(rect);
2442 let Some(path) = path.finish() else {
2443 return;
2444 };
2445 surface.set_fill(Some(krilla::paint::Fill {
2446 paint: krilla::color::rgb::Color::new(color.red, color.green, color.blue).into(),
2447 opacity,
2448 rule: Default::default(),
2449 }));
2450 surface.draw_path(&path);
2451 surface.set_fill(None);
2452}
2453
2454#[cfg(any(feature = "png", feature = "jpeg"))]
2455#[derive(Debug, Clone, Copy, Default)]
2456struct RootSvgMetadata {
2457 has_view_box: bool,
2458 max_width_px: Option<f32>,
2459}
2460
2461#[cfg(any(feature = "png", feature = "jpeg"))]
2462fn parse_root_svg_metadata(svg: &str, control: &OperationControl) -> Result<RootSvgMetadata> {
2463 use quick_xml::{XmlVersion, events::Event, name::ResolveResult, reader::NsReader};
2464
2465 let mut reader = NsReader::from_str(svg);
2466 reader.config_mut().enable_all_checks(true);
2467 loop {
2468 export_checkpoint(control)?;
2469 let event = reader.read_event().map_err(|_| ExportError::SvgParse)?;
2470 let element = match event {
2471 Event::Start(element) | Event::Empty(element) => element,
2472 Event::Eof => return Err(ExportError::SvgParse),
2473 _ => continue,
2474 };
2475
2476 let (namespace, local_name) = reader.resolver().resolve_element(element.name());
2477 let is_svg_namespace = matches!(namespace, ResolveResult::Unbound)
2478 || matches!(
2479 namespace,
2480 ResolveResult::Bound(namespace)
2481 if namespace.as_ref() == "http://www.w3.org/2000/svg"
2482 );
2483 if !is_svg_namespace || local_name.as_ref() != "svg" {
2484 return Err(ExportError::SvgParse);
2485 }
2486
2487 let mut metadata = RootSvgMetadata::default();
2488 let mut view_box_seen = false;
2489 let mut style_seen = false;
2490 for attribute in element.attributes() {
2491 export_checkpoint(control)?;
2492 let attribute = attribute.map_err(|_| ExportError::SvgParse)?;
2493 if attribute.key.as_namespace_binding().is_some() {
2494 continue;
2495 }
2496 let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
2497 let is_unbound_attribute = matches!(&namespace, ResolveResult::Unbound);
2498 let consumed_by_usvg = match namespace {
2499 ResolveResult::Unknown(_) => return Err(ExportError::SvgParse),
2500 ResolveResult::Unbound => true,
2501 ResolveResult::Bound(namespace) => matches!(
2502 namespace.as_ref(),
2503 "http://www.w3.org/2000/svg"
2504 | "http://www.w3.org/1999/xlink"
2505 | "http://www.w3.org/XML/1998/namespace"
2506 ),
2507 };
2508 if !consumed_by_usvg {
2509 continue;
2510 }
2511
2512 let value = attribute
2513 .normalized_value(XmlVersion::Implicit1_0)
2514 .map_err(|_| ExportError::SvgParse)?;
2515 match local_name.as_ref() {
2516 "viewBox" if !view_box_seen => {
2517 view_box_seen = true;
2518 metadata.has_view_box = has_valid_svg_view_box(value.as_ref());
2519 }
2520 "style" if is_unbound_attribute && !style_seen => {
2523 style_seen = true;
2524 metadata.max_width_px = parse_inline_max_width_px(value.as_ref(), control)?;
2525 }
2526 _ => {}
2527 }
2528 }
2529 return Ok(metadata);
2530 }
2531}
2532
2533#[cfg(any(feature = "png", feature = "jpeg"))]
2534fn has_valid_svg_view_box(value: &str) -> bool {
2535 let Ok(view_box) = value.parse::<svgtypes::ViewBox>() else {
2536 return false;
2537 };
2538 usvg::NonZeroRect::from_xywh(
2539 view_box.x as f32,
2540 view_box.y as f32,
2541 view_box.w as f32,
2542 view_box.h as f32,
2543 )
2544 .is_some()
2545}
2546
2547#[cfg(any(feature = "png", feature = "jpeg"))]
2548fn parse_inline_max_width_px(style: &str, control: &OperationControl) -> Result<Option<f32>> {
2549 let mut parser = Parser::new(style);
2550 let mut max_width = None;
2551
2552 while !parser.is_exhausted() {
2553 export_checkpoint(control)?;
2554 let declaration = parser.parse_until_after(Delimiter::Semicolon, |declaration| {
2555 let property = declaration.expect_ident_cloned()?;
2556 declaration.expect_colon()?;
2557
2558 if !property.eq_ignore_ascii_case("max-width") {
2559 declaration.expect_no_error_token()?;
2560 return Ok::<_, cssparser::ParseError<()>>(None);
2561 }
2562
2563 let token = declaration.next()?.clone();
2564 declaration.expect_exhausted()?;
2565 let Token::Dimension { value, unit, .. } = token else {
2566 return Ok(None);
2567 };
2568 if unit.eq_ignore_ascii_case("px") && value.is_finite() && value > 0.0 {
2569 Ok(Some(value))
2570 } else {
2571 Ok(None)
2572 }
2573 });
2574
2575 if let Ok(Some(value)) = declaration {
2576 max_width = Some(value);
2577 }
2578 }
2579 Ok(max_width)
2580}
2581
2582#[cfg(any(feature = "png", feature = "jpeg"))]
2583#[derive(Debug, Clone, Copy)]
2584struct RasterGeometry {
2585 min_x: f32,
2586 min_y: f32,
2587 width: f32,
2588 height: f32,
2589}
2590
2591#[cfg(any(feature = "png", feature = "jpeg"))]
2592fn raster_geometry_for_svg(metadata: RootSvgMetadata, tree: &usvg::Tree) -> (RasterGeometry, bool) {
2593 if metadata.has_view_box {
2594 let size = tree.size();
2599 return (
2600 RasterGeometry {
2601 min_x: 0.0,
2602 min_y: 0.0,
2603 width: size.width(),
2604 height: size.height(),
2605 },
2606 false,
2607 );
2608 }
2609
2610 let bbox = tree.root().abs_stroke_bounding_box();
2613 let w = bbox.width().max(1.0);
2614 let h = bbox.height().max(1.0);
2615 if w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0 {
2616 (
2617 RasterGeometry {
2618 min_x: bbox.x(),
2619 min_y: bbox.y(),
2620 width: w,
2621 height: h,
2622 },
2623 true,
2624 )
2625 } else {
2626 let size = tree.size();
2627 (
2628 RasterGeometry {
2629 min_x: 0.0,
2630 min_y: 0.0,
2631 width: size.width(),
2632 height: size.height(),
2633 },
2634 false,
2635 )
2636 }
2637}
2638
2639#[cfg(any(feature = "png", feature = "jpeg"))]
2640fn raster_plan_for_geometry(
2641 geo: RasterGeometry,
2642 options: &RasterOptions,
2643 control: &OperationControl,
2644) -> Result<RasterPlan> {
2645 export_checkpoint(control)?;
2646 if !(options.scale.is_finite() && options.scale > 0.0) {
2647 return Err(ExportError::InvalidScale);
2648 }
2649
2650 validate_fit_box(options.fit_to)?;
2651 validate_size_limit(options.size_limit)?;
2652
2653 let base_width_px = f64::from(geo.width).ceil().max(1.0);
2658 let base_height_px = f64::from(geo.height).ceil().max(1.0);
2659
2660 let fit_scale = fit_scale_for_base_size(base_width_px, base_height_px, options.fit_to);
2661 let requested_scale = fit_scale * f64::from(options.scale);
2662 let requested_width_px = requested_raster_dim_px(base_width_px * requested_scale)?;
2663 let requested_height_px = requested_raster_dim_px(base_height_px * requested_scale)?;
2664
2665 let limit_scale = size_limit_scale(
2666 base_width_px * requested_scale,
2667 base_height_px * requested_scale,
2668 options.size_limit,
2669 );
2670 let mut effective_scale = requested_scale * limit_scale;
2671 let (mut width_px, mut height_px) = raster_limited_dims(
2672 base_width_px,
2673 base_height_px,
2674 effective_scale,
2675 options.size_limit,
2676 )?;
2677
2678 if let Some(max_pixels) = options.size_limit.max_pixels {
2679 for _ in 0..8 {
2680 export_checkpoint(control)?;
2681 if u64::from(width_px) * u64::from(height_px) <= max_pixels {
2682 break;
2683 }
2684 let pixels = f64::from(width_px) * f64::from(height_px);
2685 let shrink = ((max_pixels as f64) / pixels).sqrt() * 0.999_999;
2686 effective_scale *= shrink;
2687 (width_px, height_px) = raster_limited_dims(
2688 base_width_px,
2689 base_height_px,
2690 effective_scale,
2691 options.size_limit,
2692 )?;
2693 }
2694 }
2695
2696 Ok(RasterPlan {
2697 requested_width_px,
2698 requested_height_px,
2699 width_px,
2700 height_px,
2701 requested_scale,
2702 effective_scale,
2703 limited: f64::from(width_px) != requested_width_px
2704 || f64::from(height_px) != requested_height_px,
2705 })
2706}
2707
2708#[cfg(any(feature = "png", feature = "jpeg"))]
2709fn validate_fit_box(fit: Option<RasterFitBox>) -> Result<()> {
2710 let Some(fit) = fit else {
2711 return Ok(());
2712 };
2713
2714 if fit.width.is_none() && fit.height.is_none() {
2715 return Err(ExportError::InvalidSizing(
2716 "fit_to must include a positive width or height",
2717 ));
2718 }
2719 if fit.width == Some(0) || fit.height == Some(0) {
2720 return Err(ExportError::InvalidSizing(
2721 "fit_to width and height must be positive",
2722 ));
2723 }
2724 Ok(())
2725}
2726
2727#[cfg(any(feature = "png", feature = "jpeg"))]
2728fn validate_size_limit(limit: RasterSizeLimit) -> Result<()> {
2729 if limit.max_width == Some(0) || limit.max_height == Some(0) {
2730 return Err(ExportError::InvalidSizing(
2731 "size_limit max_width and max_height must be positive",
2732 ));
2733 }
2734 if limit.max_pixels == Some(0) {
2735 return Err(ExportError::InvalidSizing(
2736 "size_limit max_pixels must be positive",
2737 ));
2738 }
2739 Ok(())
2740}
2741
2742#[cfg(any(feature = "png", feature = "jpeg"))]
2743fn fit_scale_for_base_size(width: f64, height: f64, fit: Option<RasterFitBox>) -> f64 {
2744 let Some(fit) = fit else {
2745 return 1.0;
2746 };
2747
2748 let mut scale: f64 = 1.0;
2749 if let Some(target_width) = fit.width {
2750 scale = scale.min(f64::from(target_width) / width);
2751 }
2752 if let Some(target_height) = fit.height {
2753 scale = scale.min(f64::from(target_height) / height);
2754 }
2755 if scale.is_nan() {
2756 1.0
2757 } else {
2758 scale.clamp(0.0, 1.0)
2759 }
2760}
2761
2762#[cfg(any(feature = "png", feature = "jpeg"))]
2763fn size_limit_scale(width: f64, height: f64, limit: RasterSizeLimit) -> f64 {
2764 let mut scale: f64 = 1.0;
2765 if let Some(max_width) = limit.max_width {
2766 scale = scale.min(f64::from(max_width) / width);
2767 }
2768 if let Some(max_height) = limit.max_height {
2769 scale = scale.min(f64::from(max_height) / height);
2770 }
2771 if let Some(max_pixels) = limit.max_pixels {
2772 let pixels = width * height * scale * scale;
2773 if pixels > max_pixels as f64 {
2774 scale *= ((max_pixels as f64) / pixels).sqrt();
2775 }
2776 }
2777 if scale.is_nan() {
2778 1.0
2779 } else {
2780 scale.clamp(0.0, 1.0)
2781 }
2782}
2783
2784#[cfg(any(feature = "png", feature = "jpeg"))]
2785fn raster_limited_dims(
2786 base_width_px: f64,
2787 base_height_px: f64,
2788 scale: f64,
2789 limit: RasterSizeLimit,
2790) -> Result<(u32, u32)> {
2791 Ok((
2792 raster_dim_px(base_width_px * scale, limit.max_width)?,
2793 raster_dim_px(base_height_px * scale, limit.max_height)?,
2794 ))
2795}
2796
2797#[cfg(any(feature = "png", feature = "jpeg"))]
2798fn raster_dim_px(value: f64, max: Option<u32>) -> Result<u32> {
2799 let value = requested_raster_dim_px(value)?;
2800 let value = max.map_or(value, |max| value.min(f64::from(max)));
2801 if value > f64::from(u32::MAX) {
2802 return Err(ExportError::InvalidSizing(
2803 "final raster dimension exceeds the u32 encoder capability",
2804 ));
2805 }
2806 Ok(value as u32)
2807}
2808
2809#[cfg(any(feature = "png", feature = "jpeg"))]
2810fn requested_raster_dim_px(value: f64) -> Result<f64> {
2811 if !(value.is_finite() && value > 0.0) {
2812 return Err(ExportError::InvalidSizing(
2813 "computed raster dimension must be finite and positive",
2814 ));
2815 }
2816 Ok(value.ceil().max(1.0))
2817}
2818
2819#[cfg(any(feature = "png", feature = "jpeg"))]
2820fn configure_usvg_options_for_raster(
2821 opt: &mut usvg::Options<'_>,
2822 metadata: RootSvgMetadata,
2823 options: &RasterOptions,
2824 control: &OperationControl,
2825) -> Result<()> {
2826 #[cfg(feature = "native-fonts")]
2827 if let Some(context) = &options.font_context {
2828 context.check()?;
2829 context.apply_to_usvg_options_controlled(opt, control);
2830 } else {
2831 configure_legacy_font_options(opt);
2832 }
2833 #[cfg(not(feature = "native-fonts"))]
2834 {
2835 let _ = (options, control);
2836 configure_legacy_font_options(opt);
2837 }
2838
2839 if !metadata.has_view_box
2840 && let Some(max_width) = metadata.max_width_px
2841 && max_width.is_finite()
2842 && max_width > 0.0
2843 && let Some(size) = usvg::Size::from_wh(max_width, opt.default_size.height())
2844 {
2845 opt.default_size = size;
2846 }
2847
2848 opt.image_href_resolver = data_url_only_image_href_resolver();
2849 Ok(())
2850}
2851
2852#[cfg(feature = "pdf")]
2853fn configure_usvg_options_for_pdf(
2854 opt: &mut usvg::Options<'_>,
2855 options: &PdfOptions,
2856 control: &OperationControl,
2857) -> Result<()> {
2858 #[cfg(feature = "native-fonts")]
2859 if let Some(context) = &options.font_context {
2860 context.check()?;
2861 context.apply_to_usvg_options_controlled(opt, control);
2862 } else {
2863 configure_legacy_font_options(opt);
2864 }
2865 #[cfg(not(feature = "native-fonts"))]
2866 {
2867 let _ = (options, control);
2868 configure_legacy_font_options(opt);
2869 }
2870 opt.image_href_resolver = data_url_only_image_href_resolver();
2871 Ok(())
2872}
2873
2874#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2875fn configure_legacy_font_options(opt: &mut usvg::Options<'_>) {
2876 opt.fontdb = shared_system_fontdb();
2877 opt.font_family =
2878 raster_default_font_family(opt.fontdb.as_ref()).unwrap_or_else(|| "Arial".to_string());
2879 opt.font_resolver = browser_like_font_resolver();
2880}
2881
2882#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2883fn shared_system_fontdb() -> Arc<usvg::fontdb::Database> {
2884 static FONTDB: OnceLock<Arc<usvg::fontdb::Database>> = OnceLock::new();
2885 Arc::clone(FONTDB.get_or_init(|| {
2886 let mut fontdb = usvg::fontdb::Database::new();
2887 fontdb.load_system_fonts();
2888 configure_fontdb_generic_families(&mut fontdb);
2889 Arc::new(fontdb)
2890 }))
2891}
2892
2893#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
2894fn data_url_only_image_href_resolver() -> usvg::ImageHrefResolver<'static> {
2895 usvg::ImageHrefResolver {
2896 resolve_data: usvg::ImageHrefResolver::default_data_resolver(),
2897 resolve_string: Box::new(|_, _| None),
2898 }
2899}
2900
2901#[cfg(any(
2902 feature = "native-fonts",
2903 feature = "png",
2904 feature = "jpeg",
2905 feature = "pdf"
2906))]
2907fn browser_like_font_resolver() -> usvg::FontResolver<'static> {
2908 usvg::FontResolver {
2909 select_font: Box::new(move |font, fontdb| {
2910 select_font_case_insensitively(font, fontdb.as_ref())
2911 .or_else(|| query_browser_like_fallback_font(font, fontdb.as_ref()))
2912 .or_else(|| fontdb.faces().next().map(|face| face.id))
2913 }),
2914 select_fallback: usvg::FontResolver::default_fallback_selector(),
2915 }
2916}
2917
2918#[cfg(any(
2919 feature = "native-fonts",
2920 feature = "png",
2921 feature = "jpeg",
2922 feature = "pdf"
2923))]
2924fn select_font_case_insensitively(
2925 font: &usvg::Font,
2926 fontdb: &usvg::fontdb::Database,
2927) -> Option<usvg::fontdb::ID> {
2928 let weight = usvg::fontdb::Weight(font.weight());
2929 let stretch = font.stretch().into();
2930 let style = font.style().into();
2931
2932 for family in font.families() {
2933 let selected = match family {
2934 usvg::FontFamily::Named(name) => {
2935 query_named_font_family_case_insensitively(fontdb, name, weight, stretch, style)
2936 }
2937 usvg::FontFamily::Serif => {
2938 query_font_family(fontdb, usvg::fontdb::Family::Serif, weight, stretch, style)
2939 }
2940 usvg::FontFamily::SansSerif => query_font_family(
2941 fontdb,
2942 usvg::fontdb::Family::SansSerif,
2943 weight,
2944 stretch,
2945 style,
2946 ),
2947 usvg::FontFamily::Cursive => query_font_family(
2948 fontdb,
2949 usvg::fontdb::Family::Cursive,
2950 weight,
2951 stretch,
2952 style,
2953 ),
2954 usvg::FontFamily::Fantasy => query_font_family(
2955 fontdb,
2956 usvg::fontdb::Family::Fantasy,
2957 weight,
2958 stretch,
2959 style,
2960 ),
2961 usvg::FontFamily::Monospace => query_font_family(
2962 fontdb,
2963 usvg::fontdb::Family::Monospace,
2964 weight,
2965 stretch,
2966 style,
2967 ),
2968 };
2969 if selected.is_some() {
2970 return selected;
2971 }
2972 }
2973
2974 query_font_family(fontdb, usvg::fontdb::Family::Serif, weight, stretch, style)
2976}
2977
2978#[cfg(any(
2979 feature = "native-fonts",
2980 feature = "png",
2981 feature = "jpeg",
2982 feature = "pdf"
2983))]
2984fn query_named_font_family_case_insensitively(
2985 fontdb: &usvg::fontdb::Database,
2986 requested_name: &str,
2987 weight: usvg::fontdb::Weight,
2988 stretch: usvg::fontdb::Stretch,
2989 style: usvg::fontdb::Style,
2990) -> Option<usvg::fontdb::ID> {
2991 query_font_family(
2992 fontdb,
2993 usvg::fontdb::Family::Name(requested_name),
2994 weight,
2995 stretch,
2996 style,
2997 )
2998 .or_else(|| {
2999 let canonical_name = fontdb
3000 .faces()
3001 .flat_map(|face| face.families.iter())
3002 .map(|(name, _)| name)
3003 .find(|name| unicase::eq(name.as_str(), requested_name))?;
3004
3005 query_font_family(
3006 fontdb,
3007 usvg::fontdb::Family::Name(canonical_name),
3008 weight,
3009 stretch,
3010 style,
3011 )
3012 })
3013}
3014
3015#[cfg(any(
3016 feature = "native-fonts",
3017 feature = "png",
3018 feature = "jpeg",
3019 feature = "pdf"
3020))]
3021fn query_font_family(
3022 fontdb: &usvg::fontdb::Database,
3023 family: usvg::fontdb::Family<'_>,
3024 weight: usvg::fontdb::Weight,
3025 stretch: usvg::fontdb::Stretch,
3026 style: usvg::fontdb::Style,
3027) -> Option<usvg::fontdb::ID> {
3028 let families = [family];
3029 fontdb.query(&usvg::fontdb::Query {
3030 families: &families,
3031 weight,
3032 stretch,
3033 style,
3034 })
3035}
3036
3037#[cfg(any(
3038 feature = "native-fonts",
3039 feature = "png",
3040 feature = "jpeg",
3041 feature = "pdf"
3042))]
3043fn configure_fontdb_generic_families(fontdb: &mut usvg::fontdb::Database) {
3044 let sans = first_font_family(fontdb, |face| !face.monospaced)
3045 .or_else(|| first_font_family(fontdb, |_| true));
3046 let mono = first_font_family(fontdb, |face| face.monospaced).or_else(|| sans.clone());
3047
3048 if query_normal_font_family(fontdb, usvg::fontdb::Family::SansSerif).is_none()
3049 && let Some(family) = sans.as_ref()
3050 {
3051 fontdb.set_sans_serif_family(family.clone());
3052 }
3053 if query_normal_font_family(fontdb, usvg::fontdb::Family::Serif).is_none()
3054 && let Some(family) = sans.as_ref()
3055 {
3056 fontdb.set_serif_family(family.clone());
3057 }
3058 if query_normal_font_family(fontdb, usvg::fontdb::Family::Monospace).is_none()
3059 && let Some(family) = mono.as_ref()
3060 {
3061 fontdb.set_monospace_family(family.clone());
3062 }
3063}
3064
3065#[cfg(any(
3066 feature = "native-fonts",
3067 feature = "png",
3068 feature = "jpeg",
3069 feature = "pdf"
3070))]
3071fn raster_default_font_family(fontdb: &usvg::fontdb::Database) -> Option<String> {
3072 query_normal_font_family(fontdb, usvg::fontdb::Family::SansSerif)
3073 .or_else(|| query_normal_font_family(fontdb, usvg::fontdb::Family::Serif))
3074 .or_else(|| first_font_family(fontdb, |_| true))
3075}
3076
3077#[cfg(any(
3078 feature = "native-fonts",
3079 feature = "png",
3080 feature = "jpeg",
3081 feature = "pdf"
3082))]
3083fn query_browser_like_fallback_font(
3084 font: &usvg::Font,
3085 fontdb: &usvg::fontdb::Database,
3086) -> Option<usvg::fontdb::ID> {
3087 let mut families = Vec::with_capacity(3);
3088 if font_requests_monospace(font) {
3089 families.push(usvg::fontdb::Family::Monospace);
3090 families.push(usvg::fontdb::Family::SansSerif);
3091 families.push(usvg::fontdb::Family::Serif);
3092 } else {
3093 families.push(usvg::fontdb::Family::SansSerif);
3094 families.push(usvg::fontdb::Family::Serif);
3095 families.push(usvg::fontdb::Family::Monospace);
3096 }
3097
3098 let query = usvg::fontdb::Query {
3099 families: &families,
3100 weight: usvg::fontdb::Weight(font.weight()),
3101 stretch: font.stretch().into(),
3102 style: font.style().into(),
3103 };
3104 fontdb.query(&query)
3105}
3106
3107#[cfg(any(
3108 feature = "native-fonts",
3109 feature = "png",
3110 feature = "jpeg",
3111 feature = "pdf"
3112))]
3113fn query_normal_font_family(
3114 fontdb: &usvg::fontdb::Database,
3115 family: usvg::fontdb::Family<'_>,
3116) -> Option<String> {
3117 let families = [family];
3118 let query = usvg::fontdb::Query {
3119 families: &families,
3120 weight: usvg::fontdb::Weight::NORMAL,
3121 stretch: usvg::fontdb::Stretch::Normal,
3122 style: usvg::fontdb::Style::Normal,
3123 };
3124 fontdb
3125 .query(&query)
3126 .and_then(|id| fontdb.face(id))
3127 .and_then(face_family_name)
3128}
3129
3130#[cfg(any(
3131 feature = "native-fonts",
3132 feature = "png",
3133 feature = "jpeg",
3134 feature = "pdf"
3135))]
3136fn first_font_family<F>(fontdb: &usvg::fontdb::Database, mut predicate: F) -> Option<String>
3137where
3138 F: FnMut(&usvg::fontdb::FaceInfo) -> bool,
3139{
3140 fontdb
3141 .faces()
3142 .find(|face| predicate(face))
3143 .and_then(face_family_name)
3144}
3145
3146#[cfg(any(
3147 feature = "native-fonts",
3148 feature = "png",
3149 feature = "jpeg",
3150 feature = "pdf"
3151))]
3152fn face_family_name(face: &usvg::fontdb::FaceInfo) -> Option<String> {
3153 face.families
3154 .iter()
3155 .find(|(_, lang)| *lang == usvg::fontdb::Language::English_UnitedStates)
3156 .or_else(|| face.families.first())
3157 .map(|(family, _)| family.clone())
3158}
3159
3160#[cfg(any(
3161 feature = "native-fonts",
3162 feature = "png",
3163 feature = "jpeg",
3164 feature = "pdf"
3165))]
3166fn font_requests_monospace(font: &usvg::Font) -> bool {
3167 font.families().iter().any(|family| match family {
3168 usvg::FontFamily::Monospace => true,
3169 usvg::FontFamily::Named(name) => {
3170 let name = name.to_ascii_lowercase();
3171 name.contains("mono")
3172 || name.contains("courier")
3173 || name.contains("consolas")
3174 || name.contains("menlo")
3175 }
3176 _ => false,
3177 })
3178}
3179
3180#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
3181#[derive(Debug, Clone, Copy)]
3182struct RgbaColor {
3183 red: u8,
3184 green: u8,
3185 blue: u8,
3186 alpha: u8,
3187}
3188
3189#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
3191#[must_use]
3192pub fn is_valid_export_background_color(text: &str) -> bool {
3193 parse_rgba_color(text).is_some()
3194}
3195
3196#[cfg(any(feature = "png", feature = "jpeg", feature = "pdf"))]
3197fn parse_rgba_color(text: &str) -> Option<RgbaColor> {
3198 let s = text.trim().to_ascii_lowercase();
3199 match s.as_str() {
3200 "transparent" => {
3201 return Some(RgbaColor {
3202 red: 0,
3203 green: 0,
3204 blue: 0,
3205 alpha: 0,
3206 });
3207 }
3208 "white" => {
3209 return Some(RgbaColor {
3210 red: 255,
3211 green: 255,
3212 blue: 255,
3213 alpha: 255,
3214 });
3215 }
3216 "black" => {
3217 return Some(RgbaColor {
3218 red: 0,
3219 green: 0,
3220 blue: 0,
3221 alpha: 255,
3222 });
3223 }
3224 _ => {}
3225 }
3226
3227 let hex = s.strip_prefix('#')?;
3228 fn hex2(b: &[u8]) -> Option<u8> {
3229 let hi = (*b.first()? as char).to_digit(16)? as u8;
3230 let lo = (*b.get(1)? as char).to_digit(16)? as u8;
3231 Some((hi << 4) | lo)
3232 }
3233 fn hex1(c: u8) -> Option<u8> {
3234 let v = (c as char).to_digit(16)? as u8;
3235 Some((v << 4) | v)
3236 }
3237
3238 let bytes = hex.as_bytes();
3239 match bytes.len() {
3240 3 => Some(RgbaColor {
3241 red: hex1(bytes[0])?,
3242 green: hex1(bytes[1])?,
3243 blue: hex1(bytes[2])?,
3244 alpha: 255,
3245 }),
3246 4 => Some(RgbaColor {
3247 red: hex1(bytes[0])?,
3248 green: hex1(bytes[1])?,
3249 blue: hex1(bytes[2])?,
3250 alpha: hex1(bytes[3])?,
3251 }),
3252 6 => Some(RgbaColor {
3253 red: hex2(&bytes[0..2])?,
3254 green: hex2(&bytes[2..4])?,
3255 blue: hex2(&bytes[4..6])?,
3256 alpha: 255,
3257 }),
3258 8 => Some(RgbaColor {
3259 red: hex2(&bytes[0..2])?,
3260 green: hex2(&bytes[2..4])?,
3261 blue: hex2(&bytes[4..6])?,
3262 alpha: hex2(&bytes[6..8])?,
3263 }),
3264 _ => None,
3265 }
3266}
3267
3268#[cfg(any(feature = "png", feature = "jpeg"))]
3269fn parse_tiny_skia_color(text: &str) -> Option<tiny_skia::Color> {
3270 let color = parse_rgba_color(text)?;
3271 Some(tiny_skia::Color::from_rgba8(
3272 color.red,
3273 color.green,
3274 color.blue,
3275 color.alpha,
3276 ))
3277}
3278
3279#[cfg(all(test, any(feature = "png", feature = "jpeg")))]
3280mod font_resolver_tests {
3281 use super::*;
3282 use std::sync::Mutex;
3283
3284 #[test]
3285 fn browser_font_resolver_matches_named_families_case_insensitively() {
3286 let (fontdb, expected_id) = controlled_fontdb();
3287
3288 assert_eq!(
3289 selected_font_id(Arc::clone(&fontdb), "'Maße Test Sans'"),
3290 expected_id,
3291 "the installed family spelling must select its named face"
3292 );
3293 assert_eq!(
3294 selected_font_id(Arc::clone(&fontdb), "'maße test sans'"),
3295 expected_id,
3296 "CSS family matching must ignore ASCII case"
3297 );
3298 assert_eq!(
3299 selected_font_id(Arc::clone(&fontdb), "'MASSE TEST SANS'"),
3300 expected_id,
3301 "CSS family matching must use Unicode default case folding"
3302 );
3303 assert_eq!(
3304 selected_font_id(
3305 Arc::clone(&fontdb),
3306 "'__merman_missing_font__', 'masse test sans'"
3307 ),
3308 expected_id,
3309 "case-insensitive matching must preserve CSS family stack order"
3310 );
3311 }
3312
3313 fn controlled_fontdb() -> (Arc<usvg::fontdb::Database>, usvg::fontdb::ID) {
3314 let source_face = shared_system_fontdb()
3315 .faces()
3316 .next()
3317 .cloned()
3318 .expect("native export tests require one valid system font face");
3319
3320 let mut fontdb = usvg::fontdb::Database::new();
3321 let mut fallback = source_face.clone();
3322 fallback.families = vec![(
3323 "Merman Fallback Serif".to_string(),
3324 usvg::fontdb::Language::English_UnitedStates,
3325 )];
3326 fallback.weight = usvg::fontdb::Weight::NORMAL;
3327 fallback.stretch = usvg::fontdb::Stretch::Normal;
3328 fallback.style = usvg::fontdb::Style::Normal;
3329
3330 let mut target = source_face;
3331 target.families = vec![(
3332 "Maße Test Sans".to_string(),
3333 usvg::fontdb::Language::English_UnitedStates,
3334 )];
3335 target.weight = usvg::fontdb::Weight::NORMAL;
3336 target.stretch = usvg::fontdb::Stretch::Normal;
3337 target.style = usvg::fontdb::Style::Normal;
3338
3339 fontdb.set_serif_family("Merman Fallback Serif");
3340 fontdb.set_sans_serif_family("Merman Fallback Serif");
3341 fontdb.set_monospace_family("Merman Fallback Serif");
3342 let _fallback_id = fontdb.push_face_info(fallback);
3343 let target_id = fontdb.push_face_info(target);
3344 (Arc::new(fontdb), target_id)
3345 }
3346
3347 fn selected_font_id(
3348 fontdb: Arc<usvg::fontdb::Database>,
3349 font_family: &str,
3350 ) -> usvg::fontdb::ID {
3351 let selected = Arc::new(Mutex::new(Vec::new()));
3352 let selected_for_resolver = Arc::clone(&selected);
3353 let resolver = browser_like_font_resolver();
3354 let select_font = resolver.select_font;
3355 let options = usvg::Options {
3356 fontdb,
3357 font_resolver: usvg::FontResolver {
3358 select_font: Box::new(move |font, fontdb| {
3359 let id = select_font(font, fontdb);
3360 if let Some(id) = id {
3361 selected_for_resolver
3362 .lock()
3363 .expect("selected-font recorder lock")
3364 .push(id);
3365 }
3366 id
3367 }),
3368 select_fallback: resolver.select_fallback,
3369 },
3370 ..Default::default()
3371 };
3372
3373 let svg = format!(
3374 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 60"><text x="10" y="40" font-size="24" style="font-family: {font_family};">A</text></svg>"#
3375 );
3376 usvg::Tree::from_str(&svg, &options).expect("parse test SVG with native font resolver");
3377
3378 selected
3379 .lock()
3380 .expect("selected-font recorder lock")
3381 .first()
3382 .copied()
3383 .expect("text parsing must select a primary font")
3384 }
3385}
3386
3387#[cfg(all(test, feature = "png"))]
3388mod png_feature_tests {
3389 use super::*;
3390
3391 fn compatible_svg() -> ResvgCompatibleSvg {
3392 let session = merman_render::environment::RenderEnvironment::deterministic()
3393 .begin_session()
3394 .expect("deterministic render session");
3395 merman_render::svg::finalize_resvg_svg(
3396 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#,
3397 &session,
3398 )
3399 .expect("sealed SVG")
3400 }
3401
3402 #[test]
3403 fn png_leaf_encodes_a_sealed_svg() {
3404 let bytes = svg_to_png(&compatible_svg(), &RasterOptions::default())
3405 .expect("PNG export should be callable when its leaf is enabled");
3406
3407 assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
3408 }
3409
3410 #[test]
3411 fn controlled_png_preserves_successful_bytes() {
3412 let svg = compatible_svg();
3413 let legacy = svg_to_png(&svg, &RasterOptions::default()).expect("legacy PNG export");
3414 let controlled =
3415 svg_to_png_controlled(&svg, &RasterOptions::default(), OperationControl::new())
3416 .expect("controlled PNG export");
3417
3418 assert_eq!(controlled, legacy);
3419 }
3420
3421 #[test]
3422 fn pre_cancelled_png_returns_no_prepared_artifact_or_bytes() {
3423 let svg = compatible_svg();
3424 let control = OperationControl::new();
3425 control.cancel();
3426
3427 let prepared_error =
3428 match prepare_raster_controlled(&svg, &RasterOptions::default(), control.clone()) {
3429 Ok(_) => panic!("pre-cancelled preparation must fail"),
3430 Err(error) => error,
3431 };
3432 assert!(matches!(
3433 prepared_error,
3434 ExportError::Cancelled(OperationCancelled {
3435 phase: OperationPhase::Export,
3436 ..
3437 })
3438 ));
3439 assert!(prepared_error.resource_limit_details().is_none());
3440
3441 let bytes_error = svg_to_png_controlled(&svg, &RasterOptions::default(), control)
3442 .expect_err("pre-cancelled encoding must fail");
3443 assert!(matches!(
3444 bytes_error,
3445 ExportError::Cancelled(OperationCancelled {
3446 phase: OperationPhase::Export,
3447 ..
3448 })
3449 ));
3450 }
3451
3452 #[test]
3453 fn cancellation_after_preparation_returns_no_png_bytes() {
3454 let svg = compatible_svg();
3455 let control = OperationControl::new();
3456 let prepared = prepare_raster_controlled(&svg, &RasterOptions::default(), control.clone())
3457 .expect("preparation should succeed before cancellation");
3458 control.cancel();
3459
3460 let error = prepared
3461 .encode_png()
3462 .expect_err("cancelled encoding must not return bytes");
3463 assert!(matches!(
3464 error,
3465 ExportError::Cancelled(OperationCancelled {
3466 phase: OperationPhase::Export,
3467 ..
3468 })
3469 ));
3470 assert!(error.resource_limit_details().is_none());
3471 }
3472}
3473
3474#[cfg(all(test, feature = "jpeg"))]
3475mod jpeg_feature_tests {
3476 use super::*;
3477
3478 fn compatible_svg() -> ResvgCompatibleSvg {
3479 let session = merman_render::environment::RenderEnvironment::deterministic()
3480 .begin_session()
3481 .expect("deterministic render session");
3482 merman_render::svg::finalize_resvg_svg(
3483 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#,
3484 &session,
3485 )
3486 .expect("sealed SVG")
3487 }
3488
3489 #[test]
3490 fn jpeg_leaf_encodes_a_sealed_svg() {
3491 let bytes = svg_to_jpeg(&compatible_svg(), &RasterOptions::default())
3492 .expect("JPEG export should be callable when its leaf is enabled");
3493
3494 assert!(bytes.starts_with(b"\xff\xd8\xff"));
3495 }
3496
3497 #[test]
3498 fn pre_cancelled_jpeg_returns_no_bytes() {
3499 let svg = compatible_svg();
3500 let control = OperationControl::new();
3501 control.cancel();
3502
3503 let error = svg_to_jpeg_controlled(&svg, &RasterOptions::default(), control)
3504 .expect_err("pre-cancelled JPEG encoding must fail");
3505 assert!(matches!(
3506 error,
3507 ExportError::Cancelled(OperationCancelled {
3508 phase: OperationPhase::Export,
3509 ..
3510 })
3511 ));
3512 assert!(error.resource_limit_details().is_none());
3513 }
3514}
3515
3516#[cfg(all(test, any(feature = "png", feature = "jpeg")))]
3517mod root_svg_metadata_tests {
3518 use super::*;
3519
3520 #[test]
3521 fn metadata_reads_only_the_root_svg_attributes() {
3522 let metadata = parse_root_svg_metadata(
3523 r#"<svg xmlns="http://www.w3.org/2000/svg" style="content: 'max-width: 9000px'; max-width: 400px"><g viewBox="0 0 9000 9000"/><text>viewBox="0 0 8000 8000"</text></svg>"#,
3524 &OperationControl::new(),
3525 )
3526 .expect("root SVG metadata");
3527
3528 assert!(!metadata.has_view_box);
3529 assert_eq!(metadata.max_width_px, Some(400.0));
3530 }
3531
3532 #[test]
3533 fn metadata_uses_svg_number_and_css_token_grammar() {
3534 let metadata = parse_root_svg_metadata(
3535 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="-1.5-2,41.5,120 trailing" style="max-width: 400px nonsense"/>"#,
3536 &OperationControl::new(),
3537 )
3538 .expect("root SVG metadata");
3539
3540 assert!(metadata.has_view_box);
3541 assert_eq!(metadata.max_width_px, None);
3542 }
3543
3544 #[test]
3545 fn metadata_ignores_unknown_namespaces_and_uses_first_usvg_projection() {
3546 let metadata = parse_root_svg_metadata(
3547 r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:i="urn:ignored" xmlns:s="http://www.w3.org/2000/svg" i:viewBox="0 0 9000 9000" s:viewBox="invalid" viewBox="0 0 20 10" i:style="max-width: 9000px" s:style="max-width: 200px" style="max-width: 400px"/>"#,
3548 &OperationControl::new(),
3549 )
3550 .expect("root SVG metadata");
3551
3552 assert!(
3553 !metadata.has_view_box,
3554 "the first usvg-projected viewBox is invalid, so a later alias must not replace it"
3555 );
3556 assert_eq!(
3557 metadata.max_width_px,
3558 Some(400.0),
3559 "usvg consumes only the unbound style declaration list"
3560 );
3561 }
3562}
3563
3564#[cfg(all(test, feature = "pdf"))]
3565mod pdf_feature_tests {
3566 use super::*;
3567
3568 fn compatible_svg() -> ResvgCompatibleSvg {
3569 let session = merman_render::environment::RenderEnvironment::deterministic()
3570 .begin_session()
3571 .expect("deterministic render session");
3572 merman_render::svg::finalize_resvg_svg(
3573 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#,
3574 &session,
3575 )
3576 .expect("sealed SVG")
3577 }
3578
3579 #[test]
3580 fn pdf_leaf_encodes_a_sealed_svg() {
3581 let bytes = svg_to_pdf(&compatible_svg())
3582 .expect("PDF export should be callable when its leaf is enabled");
3583
3584 assert!(bytes.starts_with(b"%PDF-"));
3585 }
3586
3587 #[test]
3588 fn pre_cancelled_pdf_returns_no_prepared_artifact_or_bytes() {
3589 let svg = compatible_svg();
3590 let control = OperationControl::new();
3591 control.cancel();
3592
3593 let prepared_error =
3594 match prepare_pdf_controlled(&svg, &PdfOptions::default(), control.clone()) {
3595 Ok(_) => panic!("pre-cancelled PDF preparation must fail"),
3596 Err(error) => error,
3597 };
3598 assert!(matches!(
3599 prepared_error,
3600 ExportError::Cancelled(OperationCancelled {
3601 phase: OperationPhase::Export,
3602 ..
3603 })
3604 ));
3605 assert!(prepared_error.resource_limit_details().is_none());
3606
3607 let bytes_error = svg_to_pdf_controlled(&svg, &PdfOptions::default(), control)
3608 .expect_err("pre-cancelled PDF encoding must fail");
3609 assert!(matches!(
3610 bytes_error,
3611 ExportError::Cancelled(OperationCancelled {
3612 phase: OperationPhase::Export,
3613 ..
3614 })
3615 ));
3616 }
3617}
3618
3619#[cfg(all(test, feature = "png", feature = "jpeg", feature = "pdf"))]
3620mod tests {
3621 use super::*;
3622 use base64::Engine as _;
3623
3624 #[test]
3625 fn backend_tree_rechecks_preserve_the_render_owned_resource_phase() {
3626 for limit_name in [
3627 merman_render::resources::SVG_BACKEND_TREE_NODES_HARD_CAP_ID,
3628 merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID,
3629 ] {
3630 let details = ExportError::SvgConversionLimit {
3631 limit_name,
3632 actual: 2,
3633 max: 1,
3634 }
3635 .resource_limit_details()
3636 .expect("backend hard cap details");
3637
3638 assert_eq!(details.limit_id, limit_name);
3639 assert_eq!(details.phase, "svg_postprocess");
3640 assert_eq!(details.actual, 2);
3641 assert_eq!(details.max, 1);
3642 assert_eq!(
3643 ExportError::SvgConversionLimit {
3644 limit_name,
3645 actual: 2,
3646 max: 1,
3647 }
3648 .resource_limit_provenance()
3649 .expect("backend hard cap provenance")
3650 .domain,
3651 OperationResourceDomain::Render
3652 );
3653 assert_eq!(export_resource_limit_output_ids(limit_name), None);
3654 assert_eq!(
3655 export_resource_profile_value(
3656 merman_render::resources::RenderResourceProfile::Interactive,
3657 limit_name,
3658 ),
3659 None
3660 );
3661 }
3662 }
3663
3664 fn compatible_svg(svg: &str) -> merman_render::svg::ResvgCompatibleSvg {
3665 let session = merman_render::environment::RenderEnvironment::deterministic()
3666 .begin_session()
3667 .unwrap();
3668 merman_render::svg::finalize_resvg_svg(svg, &session).unwrap()
3669 }
3670
3671 #[test]
3672 fn backend_failure_observes_cancellation_before_returning_the_backend_error() {
3673 let control = OperationControl::new();
3674 let job_control = control.clone();
3675 let error = run_recursive_svg_backend(&control, move |_| -> Result<()> {
3676 job_control.cancel();
3677 Err(ExportError::SvgParse)
3678 })
3679 .expect_err("the post-backend checkpoint must observe cancellation");
3680
3681 assert!(matches!(
3682 error,
3683 ExportError::Cancelled(OperationCancelled {
3684 phase: OperationPhase::Export,
3685 ..
3686 })
3687 ));
3688 }
3689
3690 #[test]
3691 fn controlled_export_replays_the_first_resource_terminal_after_later_cancellation() {
3692 let href = png_data_uri_with_declared_size(1, 1);
3693 let source = format!(
3694 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="{href}" width="10" height="10"/></svg>"#
3695 );
3696 let svg = compatible_svg(&source);
3697 let options = RasterOptions {
3698 embedded_image_limit: EmbeddedImageLimit::new(Some(16), None, None, None),
3699 ..RasterOptions::default()
3700 };
3701 let control = OperationControl::new();
3702 let first = match super::prepare_raster_controlled(&svg, &options, control.clone()) {
3703 Ok(_) => panic!("the embedded-image byte limit must reject preparation"),
3704 Err(error) => error,
3705 };
3706 let first_details = first
3707 .resource_limit_details()
3708 .expect("the first rejection must expose resource metadata");
3709 let first_terminal = control
3710 .terminal_checkpoint_at(OperationPhase::Export)
3711 .expect_err("the first export resource rejection must latch the operation terminal");
3712 assert!(matches!(
3713 &first_terminal,
3714 OperationLedgerError::ResourceLimitExceeded(error)
3715 if error.id == MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID
3716 && error.resource_phase == "embedded_image_decode"
3717 && error.limit == first_details.max
3718 && error.consumed.saturating_add(error.requested) == first_details.actual
3719 && error.provenance.domain == OperationResourceDomain::Export
3720 && error.provenance.profile.is_none()
3721 && error.provenance.explicit_overrides.is_empty()
3722 ));
3723
3724 control.cancel();
3725 let replay = match super::prepare_raster_controlled(
3726 &compatible_svg(
3727 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>"#,
3728 ),
3729 &RasterOptions::default(),
3730 control,
3731 ) {
3732 Ok(_) => panic!("the existing terminal must prevent a second prepared artifact"),
3733 Err(error) => error,
3734 };
3735
3736 assert_eq!(replay.resource_limit_details(), Some(first_details));
3737 assert!(matches!(
3738 replay,
3739 ExportError::OperationResourceTerminal(error) if error == first_terminal
3740 ));
3741 assert_eq!(
3742 first_details.limit_id,
3743 MAX_EMBEDDED_IMAGE_BYTES_RESOURCE_LIMIT_ID
3744 );
3745 assert_eq!(first_details.phase, "embedded_image_decode");
3746 assert_eq!(first_details.cause, ExportResourceLimitCause::Ceiling);
3747 }
3748
3749 #[test]
3750 fn export_checkpoint_preserves_foreign_resource_terminal_provenance() {
3751 let control = OperationControl::new();
3752 let provenance = OperationResourceProvenance::new(
3753 OperationResourceDomain::Render,
3754 Some(merman_core::resources::ResourceProfile::Constrained),
3755 [merman_core::OperationResourceOverride {
3756 id: "max_svg_bytes",
3757 value: 17,
3758 }],
3759 );
3760 let terminal = control.terminate_resource_overflow(
3761 "max_svg_bytes",
3762 OperationPhase::Postprocess,
3763 "svg_postprocess",
3764 u64::MAX,
3765 17,
3766 provenance,
3767 );
3768
3769 let error = export_checkpoint(&control)
3770 .expect_err("the export adapter must replay the foreign terminal");
3771
3772 assert!(matches!(
3773 error,
3774 ExportError::OperationResourceTerminal(actual) if actual == terminal
3775 ));
3776 }
3777
3778 #[test]
3779 fn exporter_image_resolver_never_reads_string_hrefs() {
3780 let resolver = data_url_only_image_href_resolver();
3781 let options = usvg::Options::default();
3782
3783 for href in [
3784 "/tmp/secret.png",
3785 "../secret.png",
3786 r"\\server\share\secret.png",
3787 r"C:\private\secret.png",
3788 "https://example.com/remote.png",
3789 ] {
3790 assert!(
3791 (resolver.resolve_string)(href, &options).is_none(),
3792 "string href unexpectedly resolved: {href}"
3793 );
3794 }
3795 }
3796
3797 #[test]
3798 fn export_environment_contract_matches_font_and_image_owners() {
3799 for output_id in ["jpeg", "pdf", "png"] {
3800 let contract = output_environment_contract(output_id)
3801 .expect("each compiled export must disclose its environment");
3802 let system_fonts = contract
3803 .system_fonts
3804 .expect("native exports must disclose host system fonts");
3805 assert_eq!(system_fonts.source_id, "host-system");
3806 assert_eq!(system_fonts.discovery, "first-use");
3807 assert_eq!(system_fonts.cache_scope, "process-global");
3808 assert!(system_fonts.host_dependent);
3809 assert!(!system_fonts.resource_bounded);
3810 assert_eq!(contract.embedded_images.source_ids, ["data-url"]);
3811 assert!(!contract.embedded_images.filesystem_access);
3812 assert!(!contract.embedded_images.network_access);
3813 assert_eq!(
3814 contract.embedded_images.default_limits,
3815 EmbeddedImageLimit::default()
3816 );
3817 }
3818 assert!(output_environment_contract("svg").is_none());
3819 assert!(output_environment_contract("ascii").is_none());
3820 }
3821
3822 #[test]
3823 fn system_font_database_is_process_cached() {
3824 let first = shared_system_fontdb();
3825 let second = shared_system_fontdb();
3826 assert!(Arc::ptr_eq(&first, &second));
3827 }
3828
3829 fn trusted_compatible_svg(svg: &str) -> merman_render::svg::ResvgCompatibleSvg {
3830 let session = merman_render::environment::RenderEnvironment::deterministic()
3831 .with_resource_policy(merman_render::resources::RenderResourcePolicy::trusted_native())
3832 .begin_session()
3833 .unwrap();
3834 merman_render::svg::finalize_resvg_svg(svg, &session).unwrap()
3835 }
3836
3837 fn svg_to_png(svg: &str, options: &RasterOptions) -> Result<Vec<u8>> {
3838 super::svg_to_png(&compatible_svg(svg), options)
3839 }
3840
3841 fn svg_to_jpeg(svg: &str, options: &RasterOptions) -> Result<Vec<u8>> {
3842 super::svg_to_jpeg(&compatible_svg(svg), options)
3843 }
3844
3845 fn svg_to_pdf(svg: &str) -> Result<Vec<u8>> {
3846 super::svg_to_pdf(&compatible_svg(svg))
3847 }
3848
3849 fn nested_group_svg(depth: usize) -> String {
3850 let mut svg =
3851 String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">"#);
3852 svg.push_str(&r#"<g opacity="0.999">"#.repeat(depth - 1));
3853 svg.push_str(r#"<rect width="10" height="10" fill="black"/>"#);
3854 svg.push_str(&"</g>".repeat(depth - 1));
3855 svg.push_str("</svg>");
3856 svg
3857 }
3858
3859 fn expanded_use_chain_svg(depth: usize) -> String {
3860 let mut svg =
3861 String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs>"#);
3862 for index in 0..depth {
3863 if index + 1 == depth {
3864 svg.push_str(&format!(
3865 r#"<g id="use-{index}" opacity="0.999"><rect width="10" height="10"/></g>"#
3866 ));
3867 } else {
3868 svg.push_str(&format!(
3869 r##"<g id="use-{index}" opacity="0.999"><use href="#use-{}"/></g>"##,
3870 index + 1
3871 ));
3872 }
3873 }
3874 svg.push_str(r##"</defs><use href="#use-0"/></svg>"##);
3875 svg
3876 }
3877
3878 fn branching_data_image_use_svg(levels: usize, href: &str) -> String {
3879 let mut svg =
3880 String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs>"#);
3881 svg.push_str(&format!(
3882 r#"<g id="leaf"><image href="{href}" width="1" height="1"/></g>"#
3883 ));
3884 for index in 0..levels {
3885 let target = if index + 1 == levels {
3886 "leaf".to_owned()
3887 } else {
3888 format!("use-{}", index + 1)
3889 };
3890 svg.push_str(&format!(
3891 r##"<g id="use-{index}"><use href="#{target}"/><use href="#{target}"/></g>"##
3892 ));
3893 }
3894 svg.push_str(r##"</defs><use href="#use-0"/></svg>"##);
3895 svg
3896 }
3897
3898 fn svg_to_pdf_with_options(svg: &str, options: &PdfOptions) -> Result<Vec<u8>> {
3899 super::svg_to_pdf_with_options(&compatible_svg(svg), options)
3900 }
3901
3902 fn prepare_pdf(svg: &str, options: &PdfOptions) -> Result<PreparedPdf> {
3903 super::prepare_pdf(&compatible_svg(svg), options)
3904 }
3905
3906 fn prepare_raster(svg: &str, options: &RasterOptions) -> Result<PreparedRaster> {
3907 super::prepare_raster(&compatible_svg(svg), options)
3908 }
3909
3910 fn svg_raster_plan(svg: &str, options: &RasterOptions) -> Result<RasterPlan> {
3911 super::svg_raster_plan(&compatible_svg(svg), options)
3912 }
3913
3914 #[test]
3915 fn svg_to_png_produces_png_signature() {
3916 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#;
3917 let bytes = svg_to_png(svg, &RasterOptions::default()).unwrap();
3918 assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
3919 }
3920
3921 #[test]
3922 fn root_viewport_dimensions_drive_raster_planning_and_pixels() {
3923 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#;
3924 let options = RasterOptions::default();
3925 let plan = svg_raster_plan(svg, &options).unwrap();
3926
3927 assert_eq!(plan.requested_width_px, 200.0);
3928 assert_eq!(plan.requested_height_px, 100.0);
3929 assert_eq!((plan.width_px, plan.height_px), (200, 100));
3930
3931 let bytes = svg_to_png(svg, &options).unwrap();
3932 assert_eq!(png_size(&bytes), (200, 100));
3933 let center = rgba_pixel(&bytes, 100, 50);
3934 let side = rgba_pixel(&bytes, 10, 50);
3935 assert!(
3936 center[0] < 8 && center[1] < 8 && center[2] < 8 && center[3] > 247,
3937 "expected the viewBox content at the viewport center, got {center:?}"
3938 );
3939 assert_eq!(
3940 side,
3941 [0, 0, 0, 0],
3942 "preserveAspectRatio should leave transparent side padding"
3943 );
3944 }
3945
3946 #[test]
3947 fn ignored_and_later_viewbox_aliases_do_not_change_usvg_geometry() {
3948 for svg in [
3949 r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:i="urn:ignored" i:viewBox="0 0 9000 9000"><rect width="12" height="8" fill="black"/></svg>"#,
3950 r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:s="http://www.w3.org/2000/svg" s:viewBox="invalid" viewBox="0 0 9000 9000"><rect width="12" height="8" fill="black"/></svg>"#,
3951 ] {
3952 let plan = svg_raster_plan(svg, &RasterOptions::default()).unwrap();
3953 assert_eq!(
3954 (plan.width_px, plan.height_px),
3955 (12, 8),
3956 "metadata and usvg must agree on the effective viewBox: {svg}"
3957 );
3958 }
3959 }
3960
3961 #[test]
3962 fn non_positive_root_dimensions_cannot_cross_the_sealed_raster_boundary() {
3963 let session = merman_render::environment::RenderEnvironment::deterministic()
3964 .begin_session()
3965 .unwrap();
3966
3967 for svg in [
3968 r#"<svg xmlns="http://www.w3.org/2000/svg" width="0" height="100" viewBox="0 0 10 10"/>"#,
3969 r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="-1" viewBox="0 0 10 10"/>"#,
3970 ] {
3971 let error = merman_render::svg::finalize_resvg_svg(svg, &session)
3972 .expect_err("invalid root dimensions must fail before raster preparation");
3973 assert!(
3974 error.to_string().contains("must be a positive length"),
3975 "{error}"
3976 );
3977 }
3978 }
3979
3980 #[test]
3981 #[cfg(not(target_arch = "wasm32"))]
3982 fn scheduling_weights_include_the_recursive_backend_stack() {
3983 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10"/></svg>"#;
3984 let raster = prepare_raster(svg, &RasterOptions::default()).unwrap();
3985 let pdf = prepare_pdf(svg, &PdfOptions::default()).unwrap();
3986
3987 assert!(raster.png_scheduling_weight_bytes() >= RECURSIVE_SVG_BACKEND_STACK_BYTES as u64);
3988 assert!(pdf.scheduling_weight_bytes() >= RECURSIVE_SVG_BACKEND_STACK_BYTES as u64);
3989 }
3990
3991 #[test]
3992 fn trusted_resvg_backend_handles_the_declared_tree_depth() {
3993 let depth = merman_render::resources::MAX_RESVG_TREE_DEPTH;
3994 let svg = trusted_compatible_svg(&nested_group_svg(depth));
3995 let options =
3996 RasterOptions::default().with_conversion_limits(SvgConversionLimits::unbounded());
3997 let prepared = super::prepare_raster(&svg, &options).unwrap();
3998 assert_eq!(prepared.conversion_plan().max_tree_depth, depth - 1);
3999
4000 let bytes = prepared.encode_png().unwrap();
4001 assert!(bytes.starts_with(b"\x89PNG\r\n\x1a\n"));
4002 }
4003
4004 #[test]
4005 fn trusted_krilla_backend_handles_the_declared_tree_depth() {
4006 let depth = merman_render::resources::MAX_RESVG_TREE_DEPTH;
4007 let svg = trusted_compatible_svg(&nested_group_svg(depth));
4008 let options =
4009 PdfOptions::default().with_conversion_limits(SvgConversionLimits::unbounded());
4010 let prepared = super::prepare_pdf(&svg, &options).unwrap();
4011 assert_eq!(prepared.conversion_plan().max_tree_depth, depth - 1);
4012
4013 let bytes = prepared.encode().unwrap();
4014 assert!(bytes.starts_with(b"%PDF-"));
4015 }
4016
4017 #[test]
4018 fn expanded_use_tree_is_rejected_before_usvg_parsing() {
4019 let session = merman_render::environment::RenderEnvironment::deterministic()
4020 .with_resource_policy(merman_render::resources::RenderResourcePolicy::trusted_native())
4021 .begin_session()
4022 .unwrap();
4023 let error = merman_render::svg::finalize_resvg_svg(
4024 &expanded_use_chain_svg(merman_render::resources::MAX_RESVG_TREE_DEPTH + 2),
4025 &session,
4026 )
4027 .expect_err("the reference preflight must reject the expanded usvg depth");
4028
4029 assert!(
4030 error
4031 .to_string()
4032 .contains(merman_render::resources::SVG_BACKEND_TREE_DEPTH_HARD_CAP_ID),
4033 "{error}"
4034 );
4035 }
4036
4037 #[test]
4038 fn expanded_use_data_urls_count_toward_the_aggregate_before_usvg_parsing() {
4039 let href = png_data_uri_with_declared_size(1, 1);
4040 let svg = compatible_svg(&branching_data_image_use_svg(4, &href));
4041 let one_source_resource =
4042 plan_embedded_data_resources(svg.as_str(), EmbeddedImageLimit::unbounded())
4043 .unwrap()
4044 .total_bytes;
4045 let options = RasterOptions {
4046 embedded_image_limit: EmbeddedImageLimit::new(
4047 Some(one_source_resource),
4048 Some(one_source_resource),
4049 None,
4050 None,
4051 ),
4052 ..RasterOptions::default()
4053 };
4054
4055 let error = super::prepare_raster(&svg, &options)
4056 .err()
4057 .expect("expanded data URLs must exceed the aggregate preflight budget");
4058
4059 assert!(error.to_string().contains("max_total_bytes"), "{error}");
4060 }
4061
4062 #[test]
4063 fn filter_and_marker_subroots_count_repeated_data_urls_before_usvg_parsing() {
4064 let href = png_data_uri_with_declared_size(1, 1);
4065 let cases = [
4066 format!(
4067 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><image id="source" href="{href}" width="1" height="1"/><filter id="f"><feImage href="#source"/></filter></defs><rect width="10" height="10" filter="url(#f)"/></svg>"##
4068 ),
4069 format!(
4070 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><marker id="m"><image href="{href}" width="1" height="1"/></marker></defs><path d="M0 0L1 1L2 2L3 3" marker-mid="url(#m)"/></svg>"##
4071 ),
4072 ];
4073
4074 for raw in cases {
4075 let one_source_resource =
4076 plan_embedded_data_resources(&raw, EmbeddedImageLimit::unbounded())
4077 .unwrap()
4078 .total_bytes;
4079 let svg = compatible_svg(&raw);
4080 let options = RasterOptions {
4081 embedded_image_limit: EmbeddedImageLimit::new(
4082 Some(one_source_resource),
4083 Some(one_source_resource),
4084 None,
4085 None,
4086 ),
4087 ..RasterOptions::default()
4088 };
4089
4090 let error = super::prepare_raster(&svg, &options)
4091 .err()
4092 .expect("repeated filter or marker data URLs must exceed the preflight budget");
4093 assert!(error.to_string().contains("max_total_bytes"), "{error}");
4094 }
4095 }
4096
4097 #[test]
4098 fn css_effect_fanout_counts_filter_and_mask_data_urls_before_usvg_parsing() {
4099 let href = png_data_uri_with_declared_size(1, 1);
4100 let one_resource = plan_embedded_data_resources(
4101 &format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="{href}"/></svg>"#),
4102 EmbeddedImageLimit::unbounded(),
4103 )
4104 .unwrap()
4105 .total_bytes;
4106 let raw = format!(
4107 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><filter id="f"><feImage href="{href}"/></filter><mask id="m"><image href="{href}" width="1" height="1"/></mask></defs><style>.affected {{ filter: url(#f); mask: url(#m); }}</style><rect class="affected" width="1" height="1"/><rect class="affected" x="2" width="1" height="1"/><rect class="affected" x="4" width="1" height="1"/></svg>"##
4108 );
4109 let svg = compatible_svg(&raw);
4110 let options = RasterOptions {
4111 embedded_image_limit: EmbeddedImageLimit::new(
4112 Some(one_resource),
4113 Some(one_resource.saturating_mul(2)),
4114 None,
4115 None,
4116 ),
4117 ..RasterOptions::default()
4118 };
4119
4120 let error = super::prepare_raster(&svg, &options)
4121 .err()
4122 .expect("CSS effect fanout must exceed the aggregate preflight budget");
4123
4124 assert!(error.to_string().contains("max_total_bytes"), "{error}");
4125 }
4126
4127 #[test]
4128 fn svg_to_png_does_not_load_local_image_hrefs() {
4129 let local_image = TempFile::new("png", encode_rgba_png(1, 1, &[255, 0, 0, 255]));
4130 let href = escape_xml_attr(&local_image.href_path());
4131 let svg = format!(
4132 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="white"/><image href="{href}" width="10" height="10"/></svg>"#
4133 );
4134
4135 let bytes = svg_to_png(&svg, &RasterOptions::default()).unwrap();
4136 let center = rgba_pixel(&bytes, 5, 5);
4137
4138 assert!(
4139 center[0] > 240 && center[1] > 240 && center[2] > 240,
4140 "expected local image href to be ignored, got center pixel {center:?}"
4141 );
4142 }
4143
4144 #[test]
4145 fn embedded_image_limits_reject_large_decode_before_png_or_pdf_encoding() {
4146 let href = png_data_uri_with_declared_size(100_000, 100_000);
4147 let svg = format!(
4148 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="{href}" width="10" height="10"/></svg>"#
4149 );
4150
4151 let png_err = prepare_raster(&svg, &RasterOptions::default())
4152 .err()
4153 .expect("PNG preparation should reject the decoded image size");
4154 let pdf_err = prepare_pdf(&svg, &PdfOptions::default())
4155 .err()
4156 .expect("PDF preparation should reject the decoded image size");
4157
4158 assert!(png_err.to_string().contains("max_pixels_per_image"));
4159 assert!(pdf_err.to_string().contains("max_pixels_per_image"));
4160 }
4161
4162 #[test]
4163 fn embedded_image_plan_reports_intrinsic_pixels_from_headers() {
4164 let href = png_data_uri_with_declared_size(2, 3);
4165 let svg = format!(
4166 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="{href}" width="10" height="10"/></svg>"#
4167 );
4168 let prepared = prepare_raster(&svg, &RasterOptions::default()).unwrap();
4169
4170 assert_eq!(
4171 prepared.embedded_image_plan(),
4172 EmbeddedImagePlan {
4173 data_resources: 1,
4174 raster_images: 1,
4175 largest_data_bytes: 68,
4176 total_data_bytes: 68,
4177 largest_raster_pixels: 6,
4178 total_pixels: 6,
4179 }
4180 );
4181 }
4182
4183 #[test]
4184 fn namespaced_image_and_filter_image_share_href_byte_limits() {
4185 let href = png_data_uri_with_declared_size(1, 1);
4186 let single_svg =
4187 format!(r#"<svg xmlns="http://www.w3.org/2000/svg"><image href="{href}"/></svg>"#);
4188 let bytes_per_resource =
4189 plan_embedded_data_resources(&single_svg, EmbeddedImageLimit::unbounded())
4190 .unwrap()
4191 .total_bytes;
4192 assert!(bytes_per_resource > 0);
4193 let total_bytes = bytes_per_resource * 2;
4194 let svg = format!(
4195 r#"<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><svg:image href="{href}"/><svg:defs><svg:filter id="f"><svg:feImage xlink:href="{href}"/></svg:filter></svg:defs></svg:svg>"#
4196 );
4197
4198 let plan = plan_embedded_data_resources(
4199 &svg,
4200 EmbeddedImageLimit::new(Some(bytes_per_resource), Some(total_bytes), None, None),
4201 )
4202 .expect("the exact per-resource and aggregate limits should be accepted");
4203 assert_eq!(plan.resources, 2);
4204 assert_eq!(plan.largest_bytes, bytes_per_resource);
4205 assert_eq!(plan.total_bytes, total_bytes);
4206
4207 let per_resource_error = plan_embedded_data_resources(
4208 &svg,
4209 EmbeddedImageLimit::new(Some(bytes_per_resource - 1), None, None, None),
4210 )
4211 .expect_err("both image element kinds must enforce the per-resource byte limit");
4212 assert!(matches!(
4213 per_resource_error,
4214 ExportError::EmbeddedImageLimit {
4215 limit_name: "max_bytes_per_image",
4216 actual,
4217 max,
4218 } if actual > max
4219 ));
4220
4221 let aggregate_error = plan_embedded_data_resources(
4222 &svg,
4223 EmbeddedImageLimit::new(Some(bytes_per_resource), Some(total_bytes - 1), None, None),
4224 )
4225 .expect_err("feImage must contribute to the aggregate byte limit");
4226 assert!(matches!(
4227 aggregate_error,
4228 ExportError::EmbeddedImageLimit {
4229 limit_name: "max_total_bytes",
4230 actual,
4231 max,
4232 } if actual > max
4233 ));
4234 }
4235
4236 #[test]
4237 fn external_image_hrefs_remain_outside_the_data_url_budget() {
4238 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image href="https://example.invalid/image.png"/><defs><filter id="f"><feImage xlink:href="file:///tmp/image.png"/></filter></defs></svg>"#;
4239
4240 let plan = plan_embedded_data_resources(
4241 svg,
4242 EmbeddedImageLimit::new(Some(1), Some(1), None, None),
4243 )
4244 .expect("external references are handled by the separate href resolver policy");
4245
4246 assert_eq!(plan.resources, 0);
4247 assert_eq!(plan.largest_bytes, 0);
4248 assert_eq!(plan.total_bytes, 0);
4249 }
4250
4251 #[test]
4252 fn embedded_image_bytes_are_limited_before_usvg_decodes_data_urls() {
4253 let href = png_data_uri_with_declared_size(1, 1);
4254 let svg = format!(
4255 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><image href="{href}" width="10" height="10"/></svg>"#
4256 );
4257 let options = RasterOptions {
4258 embedded_image_limit: EmbeddedImageLimit::new(Some(16), None, None, None),
4259 ..RasterOptions::default()
4260 };
4261
4262 let error = prepare_raster(&svg, &options)
4263 .err()
4264 .expect("data URL should be rejected before usvg parsing");
4265
4266 assert!(error.to_string().contains("max_bytes_per_image"));
4267 }
4268
4269 #[test]
4270 fn filter_image_bytes_are_limited_before_usvg_decodes_data_urls() {
4271 let href = png_data_uri_with_declared_size(1, 1);
4272 let svg = format!(
4273 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><filter id="f"><feImage href="{href}"/></filter></defs><rect width="10" height="10" filter="url(#f)"/></svg>"#
4274 );
4275 let raster_options = RasterOptions {
4276 embedded_image_limit: EmbeddedImageLimit::new(Some(16), None, None, None),
4277 ..RasterOptions::default()
4278 };
4279 let pdf_options = PdfOptions {
4280 embedded_image_limit: EmbeddedImageLimit::new(Some(16), None, None, None),
4281 ..PdfOptions::default()
4282 };
4283
4284 let png_error = prepare_raster(&svg, &raster_options)
4285 .err()
4286 .expect("filter data URL should be rejected before raster usvg parsing");
4287 let pdf_error = prepare_pdf(&svg, &pdf_options)
4288 .err()
4289 .expect("filter data URL should be rejected before PDF usvg parsing");
4290
4291 assert!(png_error.to_string().contains("max_bytes_per_image"));
4292 assert!(pdf_error.to_string().contains("max_bytes_per_image"));
4293 }
4294
4295 #[test]
4296 fn filter_image_pixels_are_limited_after_header_decode() {
4297 let href = png_data_uri_with_declared_size(100_000, 100_000);
4298 let svg = format!(
4299 r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 10 10"><defs><filter id="f"><feImage xlink:href="{href}"/></filter></defs><rect width="10" height="10" filter="url(#f)"/></svg>"#
4300 );
4301
4302 let png_error = prepare_raster(&svg, &RasterOptions::default())
4303 .err()
4304 .expect("filter image intrinsic pixels should be bounded for raster output");
4305 let pdf_error = prepare_pdf(&svg, &PdfOptions::default())
4306 .err()
4307 .expect("filter image intrinsic pixels should be bounded for PDF output");
4308
4309 assert!(png_error.to_string().contains("max_pixels_per_image"));
4310 assert!(pdf_error.to_string().contains("max_pixels_per_image"));
4311 }
4312
4313 #[test]
4314 fn embedded_image_limits_cover_pattern_subroots() {
4315 let href = png_data_uri_with_declared_size(100, 100);
4316 let svg = format!(
4317 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><defs><pattern id="p" patternUnits="userSpaceOnUse" width="1" height="1"><image href="{href}" width="1" height="1"/></pattern></defs><rect width="10" height="10" fill="url(#p)"/></svg>"#
4318 );
4319
4320 let options = RasterOptions {
4321 embedded_image_limit: EmbeddedImageLimit::new(None, None, Some(10), None),
4322 ..RasterOptions::default()
4323 };
4324 let error = prepare_raster(&svg, &options)
4325 .err()
4326 .expect("pattern raster image should be checked");
4327
4328 assert!(error.to_string().contains("max_pixels_per_image"));
4329 }
4330
4331 #[test]
4332 fn svg_to_pdf_produces_pdf_signature() {
4333 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><rect width="10" height="10" fill="black"/></svg>"#;
4334 let bytes = svg_to_pdf(svg).unwrap();
4335 assert!(bytes.starts_with(b"%PDF-"));
4336 }
4337
4338 #[test]
4339 fn fixed_pdf_page_policy_uses_requested_media_box() {
4340 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 20"><rect width="10" height="20" fill="black"/></svg>"#;
4341 let bytes = svg_to_pdf_with_options(
4342 svg,
4343 &PdfOptions::default()
4344 .with_background("white")
4345 .with_page_policy(PdfPagePolicy::Fixed {
4346 width_pt: 612.0,
4347 height_pt: 792.0,
4348 }),
4349 )
4350 .unwrap();
4351 let pdf = String::from_utf8_lossy(&bytes);
4352 let media_box = pdf
4353 .find("/MediaBox")
4354 .map(|start| &pdf[start..pdf.len().min(start + 80)])
4355 .expect("fixed PDF media box");
4356
4357 assert!(
4358 media_box.contains("612") && media_box.contains("792"),
4359 "{media_box}"
4360 );
4361 }
4362
4363 #[test]
4364 fn fixed_pdf_page_policy_rejects_invalid_page_dimensions() {
4365 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>"#;
4366 let err = svg_to_pdf_with_options(
4367 svg,
4368 &PdfOptions::default().with_page_policy(PdfPagePolicy::Fixed {
4369 width_pt: f32::NAN,
4370 height_pt: 792.0,
4371 }),
4372 )
4373 .unwrap_err();
4374
4375 assert!(
4376 err.to_string().contains("fixed PDF page dimensions"),
4377 "{err}"
4378 );
4379 }
4380
4381 #[test]
4382 fn css_width_pdf_page_policy_matches_browser_pixel_to_point_sizing() {
4383 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9000 9000"><rect width="9000" height="9000" fill="black"/></svg>"#;
4384 let bytes = svg_to_pdf_with_options(
4385 svg,
4386 &PdfOptions::default().with_page_policy(PdfPagePolicy::FitCssWidth {
4387 max_width_px: 800.0,
4388 }),
4389 )
4390 .unwrap();
4391 let pdf = String::from_utf8_lossy(&bytes);
4392 let media_box = pdf
4393 .find("/MediaBox")
4394 .map(|start| &pdf[start..pdf.len().min(start + 80)])
4395 .expect("CSS-sized PDF media box");
4396
4397 assert!(media_box.contains("600"), "{media_box}");
4398 }
4399
4400 #[test]
4401 fn fixed_pdf_page_scales_large_vector_source_without_pixel_allocation_limits() {
4402 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9000 9000"><rect width="9000" height="9000" fill="black"/></svg>"#;
4403 let bytes = svg_to_pdf_with_options(
4404 svg,
4405 &PdfOptions::default().with_page_policy(PdfPagePolicy::Fixed {
4406 width_pt: 612.0,
4407 height_pt: 792.0,
4408 }),
4409 )
4410 .unwrap();
4411
4412 assert!(bytes.starts_with(b"%PDF-"));
4413 }
4414
4415 #[test]
4416 fn svg_to_pdf_preserves_large_intrinsic_vector_page() {
4417 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9000 9000"><rect width="9000" height="9000" fill="black"/></svg>"#;
4418 let bytes = svg_to_pdf(svg).unwrap();
4419 let pdf = String::from_utf8_lossy(&bytes);
4420 let media_box = pdf
4421 .find("/MediaBox")
4422 .map(|start| &pdf[start..pdf.len().min(start + 80)])
4423 .expect("large PDF media box");
4424
4425 assert!(
4426 media_box.contains("9000"),
4427 "large vector dimensions should survive PDF conversion: {media_box}"
4428 );
4429 }
4430
4431 #[test]
4432 fn svg_to_pdf_rejects_invalid_filter_scale() {
4433 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"/>"#;
4434 let err = svg_to_pdf_with_options(svg, &PdfOptions::default().with_filter_scale(0.0))
4435 .unwrap_err();
4436
4437 assert!(err.to_string().contains("PDF filter_scale"), "{err}");
4438 }
4439
4440 #[test]
4441 fn pdf_filter_plan_bounds_aggregate_localized_rasterization() {
4442 let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10000 10000">
4443 <defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs>
4444 <g filter="url(#blur)"><rect width="10000" height="10000" fill="black"/></g>
4445 <g filter="url(#blur)"><rect width="10000" height="10000" fill="white"/></g>
4446 </svg>"##;
4447 let prepared = prepare_pdf(svg, &PdfOptions::default()).unwrap();
4448 let plan = prepared.filter_plan();
4449
4450 assert_eq!(plan.filtered_groups, 2);
4451 assert_eq!(plan.requested_image_pixels, 50_000_000);
4452 assert!(plan.limited, "{plan:?}");
4453 assert!(
4454 plan.effective_image_pixels <= DEFAULT_MAX_PDF_FILTER_IMAGE_PIXELS,
4455 "{plan:?}"
4456 );
4457 assert!(plan.effective_scale < plan.requested_scale, "{plan:?}");
4458 }
4459
4460 #[test]
4461 fn pdf_filter_plan_allows_explicit_trusted_unbounded_policy() {
4462 let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10000 10000">
4463 <defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs>
4464 <g filter="url(#blur)"><rect width="10000" height="10000" fill="black"/></g>
4465 <g filter="url(#blur)"><rect width="10000" height="10000" fill="white"/></g>
4466 </svg>"##;
4467 let prepared =
4468 prepare_pdf(svg, &PdfOptions::default().with_unbounded_filter_images()).unwrap();
4469 let plan = prepared.filter_plan();
4470
4471 assert_eq!(plan.requested_image_pixels, 50_000_000);
4472 assert_eq!(plan.effective_image_pixels, 50_000_000);
4473 assert!(!plan.limited, "{plan:?}");
4474 }
4475
4476 #[test]
4477 fn conversion_plan_rejects_filter_primitive_fanout_before_backend_rendering() {
4478 let primitives = (0..=DEFAULT_MAX_FILTER_PRIMITIVES_PER_FILTER)
4479 .map(|index| {
4480 format!(
4481 r#"<feColorMatrix in="SourceGraphic" result="p{index}" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"/>"#
4482 )
4483 })
4484 .collect::<String>();
4485 let svg = format!(
4486 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><filter id="f">{primitives}</filter></defs><g filter="url(#f)"><rect width="16" height="16"/></g></svg>"#
4487 );
4488
4489 let error = prepare_pdf(&svg, &PdfOptions::default())
4490 .err()
4491 .expect("filter primitive fanout should be rejected during preparation");
4492
4493 assert!(
4494 error
4495 .to_string()
4496 .contains("max_filter_primitives_per_filter"),
4497 "{error}"
4498 );
4499 }
4500
4501 #[test]
4502 fn conversion_plan_rejects_deep_isolation_before_backend_rendering() {
4503 let depth = DEFAULT_MAX_SVG_ISOLATION_DEPTH + 1;
4504 let mut svg =
4505 String::from(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">"#);
4506 svg.push_str(&r#"<g opacity="0.99">"#.repeat(depth));
4507 svg.push_str(r#"<rect width="16" height="16"/>"#);
4508 svg.push_str(&"</g>".repeat(depth));
4509 svg.push_str("</svg>");
4510
4511 let error = prepare_raster(&svg, &RasterOptions::default())
4512 .err()
4513 .expect("deep isolation should be rejected during preparation");
4514
4515 assert!(error.to_string().contains("max_isolation_depth"), "{error}");
4516 }
4517
4518 #[test]
4519 fn pdf_filter_plan_counts_only_krilla_owned_outer_filtered_groups() {
4520 let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
4521 <defs><filter id="blur"><feGaussianBlur stdDeviation="2"/></filter></defs>
4522 <g filter="url(#blur)"><g filter="url(#blur)"><rect width="100" height="100"/></g></g>
4523 </svg>"##;
4524
4525 let prepared = prepare_pdf(svg, &PdfOptions::default()).unwrap();
4526
4527 assert_eq!(prepared.filter_plan().filtered_groups, 1);
4528 assert_eq!(prepared.conversion_plan().filtered_groups, 2);
4529 assert_eq!(prepared.conversion_plan().filter_primitives, 2);
4530 }
4531
4532 #[test]
4533 fn svg_to_jpeg_defaults_to_white_background() {
4534 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8"></svg>"#;
4535 let bytes = svg_to_jpeg(svg, &RasterOptions::default()).unwrap();
4536 let img = image::load_from_memory_with_format(&bytes, image::ImageFormat::Jpeg)
4537 .unwrap()
4538 .to_rgb8();
4539 let px = img.get_pixel(0, 0);
4540
4541 assert!(
4542 px[0] > 240 && px[1] > 240 && px[2] > 240,
4543 "expected default JPG background to be white-ish, got {px:?}"
4544 );
4545 }
4546
4547 #[test]
4548 fn jpeg_encoder_limit_is_checked_before_allocating_the_pixmap() {
4549 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 65536 1"><rect width="65536" height="1" fill="black"/></svg>"#;
4550 let err = svg_to_jpeg(svg, &RasterOptions::default().with_unbounded_size()).unwrap_err();
4551
4552 assert!(matches!(err, ExportError::JpegDimensionLimit));
4553 }
4554
4555 #[test]
4556 fn svg_to_png_keeps_text_visible_when_requested_font_is_missing() {
4557 let svg = format!(
4558 r##"<svg xmlns="http://www.w3.org/2000/svg" width="100%" style="max-width: 400px; background-color: white;"><text x="100" y="40" fill="#333333" font-size="32" style="font-family: '__merman_missing_font__'; text-anchor: middle;">v{}</text></svg>"##,
4559 merman_core::baseline::PINNED_MERMAID_BASELINE_VERSION
4560 );
4561 let bytes = svg_to_png(&svg, &RasterOptions::default()).unwrap();
4562 assert_png_has_visible_non_background_ink(&bytes);
4563 }
4564
4565 #[test]
4566 fn default_plan_downscales_large_intrinsic_svg_without_allocating() {
4567 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14544.4375 6565.5"><rect width="14544.4375" height="6565.5" fill="white"/></svg>"#;
4568 let plan = svg_raster_plan(svg, &RasterOptions::default()).unwrap();
4569
4570 assert_eq!(plan.requested_width_px, 14545.0);
4571 assert_eq!(plan.requested_height_px, 6566.0);
4572 assert_eq!(plan.width_px, DEFAULT_MAX_RASTER_SIDE_LENGTH);
4573 assert!(plan.height_px < DEFAULT_MAX_RASTER_SIDE_LENGTH);
4574 assert!(plan.limited);
4575 assert!(plan.effective_scale < plan.requested_scale);
4576 }
4577
4578 #[test]
4579 fn fit_to_models_browser_preview_container_before_scale() {
4580 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500"><rect width="1000" height="500" fill="black"/></svg>"#;
4581 let options = RasterOptions::default()
4582 .with_fit_to(RasterFitBox::width(250))
4583 .with_scale(2.0);
4584 let plan = svg_raster_plan(svg, &options).unwrap();
4585
4586 assert_eq!(plan.requested_width_px, 500.0);
4587 assert_eq!(plan.requested_height_px, 250.0);
4588 assert_eq!(plan.width_px, 500);
4589 assert_eq!(plan.height_px, 250);
4590 assert!(!plan.limited);
4591 }
4592
4593 #[test]
4594 fn size_limit_caps_actual_png_dimensions() {
4595 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500"><rect width="1000" height="500" fill="black"/></svg>"#;
4596 let options = RasterOptions::default()
4597 .with_size_limit(RasterSizeLimit::max_side_length(128))
4598 .with_background("white");
4599 let bytes = svg_to_png(svg, &options).unwrap();
4600 let (width, height) = png_size(&bytes);
4601
4602 assert_eq!((width, height), (128, 64));
4603 }
4604
4605 #[test]
4606 fn size_limit_caps_by_total_pixels() {
4607 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><rect width="1000" height="1000" fill="black"/></svg>"#;
4608 let options = RasterOptions::default().with_size_limit(RasterSizeLimit::new(
4609 None,
4610 None,
4611 Some(10_000),
4612 ));
4613 let plan = svg_raster_plan(svg, &options).unwrap();
4614
4615 assert_eq!((plan.width_px, plan.height_px), (100, 100));
4616 assert!(plan.limited);
4617 }
4618
4619 #[test]
4620 fn unbounded_size_keeps_requested_dimensions() {
4621 let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9000 4500"><rect width="9000" height="4500" fill="black"/></svg>"#;
4622 let plan = svg_raster_plan(svg, &RasterOptions::default().with_unbounded_size()).unwrap();
4623
4624 assert_eq!((plan.width_px, plan.height_px), (9000, 4500));
4625 assert!(!plan.limited);
4626 }
4627
4628 fn png_size(bytes: &[u8]) -> (u32, u32) {
4629 let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
4630 let reader = decoder.read_info().expect("png read_info");
4631 let info = reader.info();
4632 (info.width, info.height)
4633 }
4634
4635 fn rgba_pixel(bytes: &[u8], x: u32, y: u32) -> [u8; 4] {
4636 let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
4637 let mut reader = decoder.read_info().expect("png read_info");
4638 let size = reader
4639 .output_buffer_size()
4640 .expect("invalid png output buffer size");
4641 let mut buf = vec![0u8; size];
4642 let info = reader.next_frame(&mut buf).expect("png next_frame");
4643 assert_eq!(info.color_type, png::ColorType::Rgba);
4644 assert_eq!(info.bit_depth, png::BitDepth::Eight);
4645 assert!(x < info.width && y < info.height);
4646 let offset = ((y * info.width + x) as usize) * 4;
4647 [
4648 buf[offset],
4649 buf[offset + 1],
4650 buf[offset + 2],
4651 buf[offset + 3],
4652 ]
4653 }
4654
4655 fn encode_rgba_png(width: u32, height: u32, data: &[u8]) -> Vec<u8> {
4656 let mut bytes = Vec::new();
4657 {
4658 let mut encoder = png::Encoder::new(&mut bytes, width, height);
4659 encoder.set_color(png::ColorType::Rgba);
4660 encoder.set_depth(png::BitDepth::Eight);
4661 let mut writer = encoder.write_header().expect("png write_header");
4662 writer.write_image_data(data).expect("png write_image_data");
4663 }
4664 bytes
4665 }
4666
4667 fn png_data_uri_with_declared_size(width: u32, height: u32) -> String {
4668 let mut png = encode_rgba_png(1, 1, &[0, 0, 0, 0]);
4669 png[16..20].copy_from_slice(&width.to_be_bytes());
4670 png[20..24].copy_from_slice(&height.to_be_bytes());
4671 format!(
4672 "data:image/png;base64,{}",
4673 base64::engine::general_purpose::STANDARD.encode(png)
4674 )
4675 }
4676
4677 fn escape_xml_attr(value: &str) -> String {
4678 value
4679 .replace('&', "&")
4680 .replace('"', """)
4681 .replace('<', "<")
4682 .replace('>', ">")
4683 }
4684
4685 struct TempFile {
4686 path: std::path::PathBuf,
4687 }
4688
4689 impl TempFile {
4690 fn new(extension: &str, data: Vec<u8>) -> Self {
4691 let path = std::env::temp_dir().join(format!(
4692 "merman-raster-{}-{}.{}",
4693 std::process::id(),
4694 line!(),
4695 extension
4696 ));
4697 std::fs::write(&path, data).expect("write temp image");
4698 Self { path }
4699 }
4700
4701 fn href_path(&self) -> String {
4702 self.path.to_string_lossy().replace('\\', "/")
4703 }
4704 }
4705
4706 impl Drop for TempFile {
4707 fn drop(&mut self) {
4708 let _ = std::fs::remove_file(&self.path);
4709 }
4710 }
4711
4712 fn assert_png_has_visible_non_background_ink(bytes: &[u8]) {
4713 let decoder = png::Decoder::new(std::io::Cursor::new(bytes));
4714 let mut reader = decoder.read_info().expect("png read_info");
4715 let size = reader
4716 .output_buffer_size()
4717 .expect("invalid png output buffer size");
4718 let mut buf = vec![0u8; size];
4719 let info = reader.next_frame(&mut buf).expect("png next_frame");
4720
4721 assert_eq!(
4722 info.color_type,
4723 png::ColorType::Rgba,
4724 "expected RGBA PNG output"
4725 );
4726 assert_eq!(
4727 info.bit_depth,
4728 png::BitDepth::Eight,
4729 "expected 8-bit PNG output"
4730 );
4731
4732 let pixels = &buf[..info.buffer_size()];
4733 let Some(background) = pixels.chunks_exact(4).next() else {
4734 panic!("expected at least one PNG pixel");
4735 };
4736 let differing_pixels = pixels
4737 .chunks_exact(4)
4738 .filter(|px| {
4739 let alpha_delta = px[3].abs_diff(background[3]) as u16;
4740 let rgb_delta = px[0].abs_diff(background[0]) as u16
4741 + px[1].abs_diff(background[1]) as u16
4742 + px[2].abs_diff(background[2]) as u16;
4743 alpha_delta > 3 || (px[3] > 0 && rgb_delta > 8)
4744 })
4745 .take(16)
4746 .count();
4747 assert!(
4748 differing_pixels >= 8,
4749 "expected visible text ink in rasterized PNG"
4750 );
4751 }
4752}
4753
4754#[cfg(all(test, any(feature = "png", feature = "jpeg", feature = "pdf")))]
4755mod id_suffix_consumer_tests {
4756 fn path_fill(svg: &str, id: &str) -> (usvg::Paint, f32) {
4757 let tree = usvg::Tree::from_str(svg, &usvg::Options::default()).unwrap();
4758 let usvg::Node::Path(path) = tree.node_by_id(id).unwrap() else {
4759 panic!("expected path")
4760 };
4761 let fill = path.fill().unwrap();
4762 (fill.paint().clone(), fill.opacity().get())
4763 }
4764
4765 #[test]
4766 fn resvg_safe_retains_hollow_markers_and_selector_specificity() {
4767 let session = merman_render::environment::RenderEnvironment::deterministic()
4768 .begin_session()
4769 .unwrap();
4770 let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" id="diagram" width="100" height="100"><style>
4771#diagram [id$="-aggregationEnd"],#diagram .aggregation{fill:transparent!important;stroke:black}
4772#diagram [id$="-extensionStart"]{fill:transparent!important}
4773#diagram [id$="-compositionEnd"]{fill:black!important}
4774#diagram [id$="-arrowhead"] path{fill:red!important}
4775#diagram [id$="-barbEnd"]{fill:blue!important}
4776#diagram [id$="-customEnd"]{fill:red!important}
4777#diagram .override{fill:blue!important}
4778</style><path id="diagram-aggregationEnd" class="aggregation" d="M0 0H10V10Z"/>
4779<path id="diagram-aggregationEnd-margin" class="aggregation" d="M20 0H30V10Z"/>
4780<path id="diagram-extensionStart" d="M0 20H10V30Z"/>
4781<path id="diagram-compositionEnd" d="M20 20H30V30Z"/>
4782<g id="diagram-arrowhead"><path id="arrow-paint" d="M40 0H50V10Z"/></g>
4783<path id="diagram-barbEnd" d="M40 20H50V30Z"/>
4784<path id="diagram-customEnd" class="override" d="M60 20H70V30Z"/>
4785</svg>"##;
4786 let safe = merman_render::svg::finalize_resvg_svg(svg, &session).unwrap();
4789 for id in [
4790 "diagram-aggregationEnd",
4791 "diagram-aggregationEnd-margin",
4792 "diagram-extensionStart",
4793 ] {
4794 assert_eq!(path_fill(safe.as_str(), id).1, 0.0, "{id}");
4795 }
4796 assert_eq!(path_fill(safe.as_str(), "diagram-compositionEnd").1, 1.0);
4797 for (id, color) in [
4798 ("arrow-paint", usvg::Color::new_rgb(255, 0, 0)),
4799 ("diagram-barbEnd", usvg::Color::new_rgb(0, 0, 255)),
4800 ("diagram-customEnd", usvg::Color::new_rgb(0, 0, 255)),
4801 ] {
4802 assert_eq!(
4803 path_fill(safe.as_str(), id),
4804 (usvg::Paint::Color(color), 1.0),
4805 "{id}"
4806 );
4807 }
4808 assert_eq!(
4809 merman_render::svg::finalize_resvg_svg(safe.as_str(), &session).unwrap(),
4810 safe
4811 );
4812 }
4813}