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