1mod device;
12mod normalize;
13
14use std::cell::{Cell, RefCell};
15use std::collections::HashMap;
16use std::io::{Cursor, Read, Seek, SeekFrom};
17use std::rc::Rc;
18use std::sync::Arc;
19
20use alpha2_time::{FrameRate, MediaTime};
21use base64::Engine as _;
22use kurbo::{Affine, PathEl, Shape};
23use peniko::{BlendMode as PenikoBlend, Brush, Compose, Extend, GradientKind, Mix};
24use resvg::tiny_skia::{
25 self, BlendMode, Color, FillRule, GradientStop, IntRect, IntSize, LineCap, LineJoin,
26 LinearGradient, Mask, Paint, PathBuilder, PathStroker, Pixmap, PixmapPaint, Point,
27 RadialGradient, Shader, SpreadMode, Stroke, StrokeDash, SweepGradient, Transform,
28};
29use serde_json::Value;
30use velato::model::ImageAsset;
31use velato::{Composition, RenderSink, Renderer};
32
33use alpha2_model::{MAX_LOTTIE_DOCUMENT_BYTES, ProviderSource};
34
35use crate::api::{
36 Diagnostic, ExtensionError, Format, FramePixels, ProbeReport, ResourceResolver, Timing,
37 VisualFrame, VisualProvider, VisualSource,
38};
39use crate::budget::{check_canvas, check_document_size, check_image_pixels};
40
41const MAX_LOTTIE_FRAMES: f64 = 100_000.0;
43const MAX_LOTTIE_BYTES: u64 = 256 * 1024 * 1024;
49const MAX_DOTLOTTIE_FILES: usize = 1_024;
51const MAX_DOTLOTTIE_DIRECTORY_BYTES: u64 = 1_024 * 1_024;
54
55pub struct LottieProvider;
57
58impl VisualProvider for LottieProvider {
59 fn name(&self) -> &'static str {
60 "lottie"
61 }
62
63 fn probe(&self, bytes: &[u8]) -> Option<Result<ProbeReport, ExtensionError>> {
64 let size = check_document_size(
65 &ProviderSource::Lottie { animation: None },
66 bytes,
67 bytes.len() as u64,
68 );
69 if is_zip(bytes) {
70 return Some(
71 size.and_then(|()| Package::read(bytes))
72 .and_then(|package| package.probe()),
73 );
74 }
75 if is_json_object(bytes)
76 && let Err(error) = size
77 {
78 return Some(Err(error));
79 }
80 let json = parse_lottie(bytes)?;
81 Some(inspect(json, Format::Lottie).map(|(report, _)| report))
82 }
83
84 fn open(
85 &self,
86 bytes: Arc<[u8]>,
87 source: &ProviderSource,
88 resolver: &dyn ResourceResolver,
89 ) -> Result<Box<dyn VisualSource>, ExtensionError> {
90 Ok(Box::new(LottieSource::open(&bytes, source, resolver)?))
91 }
92}
93
94impl LottieSource {
95 fn open(
96 bytes: &[u8],
97 source: &ProviderSource,
98 resolver: &dyn ResourceResolver,
99 ) -> Result<Self, ExtensionError> {
100 let item = match source {
101 ProviderSource::Lottie { animation } => animation.as_deref(),
102 _ => return Err(ExtensionError::Mismatch("not a Lottie source".into())),
103 };
104 check_document_size(source, bytes, bytes.len() as u64)?;
105 let (json, package, format) = if is_zip(bytes) {
106 let package = Package::read(bytes)?;
107 (package.animation(item)?, Some(package), Format::DotLottie)
108 } else {
109 if item.is_some() {
110 return Err(ExtensionError::Mismatch(
111 "a Lottie JSON file holds one animation".into(),
112 ));
113 }
114 let json = parse_lottie(bytes)
115 .ok_or_else(|| ExtensionError::Mismatch("not a Lottie animation".into()))?;
116 (json, None, Format::Lottie)
117 };
118 let (mut report, sanitized) = inspect(json, format)?;
119 if let Some(package) = &package {
120 report.items.clone_from(&package.ids);
121 report.item = Some(item.unwrap_or(&package.ids[0]).to_owned());
122 }
123 let text = serde_json::to_vec(&sanitized)
126 .map_err(|error| ExtensionError::Malformed(error.to_string()))?;
127 drop(sanitized);
128 let composition = Composition::from_slice(&text)
129 .map_err(|error| ExtensionError::Malformed(format!("{error:?}")))?;
130 drop(text);
131 let assets = composition
134 .images
135 .iter()
136 .map(|(id, asset)| Ok((id, asset, image_bytes(asset, package.as_ref(), resolver)?)))
137 .collect::<Result<Vec<_>, ExtensionError>>()?;
138 check_image_pixels(
139 assets
140 .iter()
141 .filter_map(|(_, _, bytes)| crate::raster::png_size(bytes.as_deref()?))
142 .filter(|(width, height)| check_canvas(*width, *height).is_ok())
143 .map(|(width, height)| u64::from(width) * u64::from(height))
144 .fold(0, u64::saturating_add),
145 )?;
146 let mut images = HashMap::new();
147 for (id, asset, bytes) in assets {
148 match bytes.and_then(|bytes| decode_image(&bytes)) {
149 Some(pixmap) => {
150 images.insert(id.clone(), pixmap);
151 }
152 None => report.diagnostics.push(Diagnostic::MissingImage {
153 href: format!(
154 "{}{}",
155 asset.directory.clone().unwrap_or_default(),
156 short(&asset.file_name)
157 ),
158 }),
159 }
160 }
161 Ok(Self {
162 composition,
163 renderer: Renderer::new(),
164 images,
165 report,
166 last: None,
167 })
168 }
169}
170
171fn is_zip(bytes: &[u8]) -> bool {
172 bytes.starts_with(b"PK\x03\x04")
173}
174
175fn is_json_object(bytes: &[u8]) -> bool {
177 bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'{')
178}
179
180fn parse_lottie(bytes: &[u8]) -> Option<Value> {
183 if !is_json_object(bytes) {
184 return None;
185 }
186 let json: Value = serde_json::from_slice(bytes).ok()?;
187 let root = json.as_object()?;
188 ["w", "h", "fr", "ip", "op"]
189 .iter()
190 .all(|key| root.get(*key).is_some_and(Value::is_number))
191 .then_some(())
192 .filter(|()| root.get("layers").is_some_and(Value::is_array))
193 .map(|()| json)
194}
195
196fn check_directory(bytes: &[u8]) -> Result<(), ExtensionError> {
211 const END: &[u8] = b"PK\x05\x06";
212 const END64_LOCATOR: &[u8] = b"PK\x06\x07";
213 const END64: &[u8] = b"PK\x06\x06";
214 let malformed = |what: &str| ExtensionError::Malformed(format!("dotLottie: {what}"));
215 let field = |at: usize, size: usize| {
216 bytes.get(at..at + size).map(|field| {
217 field
218 .iter()
219 .rev()
220 .fold(0_u64, |value, byte| value << 8 | u64::from(*byte))
221 })
222 };
223 let from = bytes.len().saturating_sub(22 + usize::from(u16::MAX));
224 let end = (from..bytes.len().saturating_sub(21))
225 .rev()
226 .find(|&at| {
227 bytes[at..].starts_with(END)
228 && field(at + 20, 2)
229 .is_some_and(|comment| at as u64 + 22 + comment <= bytes.len() as u64)
230 })
231 .ok_or_else(|| malformed("no end of central directory record"))?;
232 let (mut files, mut size) = (field(end + 10, 2), field(end + 12, 4));
233 let zip64 =
234 files == Some(u64::from(u16::MAX)) || field(end + 16, 4) == Some(u64::from(u32::MAX));
235 if zip64
236 && let Some(locator) = end.checked_sub(20)
237 && bytes[locator..].starts_with(END64_LOCATOR)
238 {
239 let at = field(locator + 8, 8)
240 .and_then(|at| usize::try_from(at).ok())
241 .filter(|&at| at < locator)
242 .and_then(|start| {
243 bytes[start..locator]
244 .windows(END64.len())
245 .position(|window| window == END64)
246 .map(|found| start + found)
247 })
248 .ok_or_else(|| malformed("no ZIP64 end of central directory record"))?;
249 (files, size) = (field(at + 32, 8), field(at + 40, 8));
250 }
251 let (Some(files), Some(size)) = (files, size) else {
252 return Err(malformed("the end of central directory record is cut off"));
253 };
254 if files > MAX_DOTLOTTIE_FILES as u64 {
255 return Err(ExtensionError::Budget(format!(
256 "the dotLottie package holds more than {MAX_DOTLOTTIE_FILES} files"
257 )));
258 }
259 if size > MAX_DOTLOTTIE_DIRECTORY_BYTES {
260 return Err(ExtensionError::Budget(format!(
261 "the dotLottie package's central directory takes more than \
262 {MAX_DOTLOTTIE_DIRECTORY_BYTES} bytes"
263 )));
264 }
265 Ok(())
266}
267
268const MAX_DIRECTORY_SEARCH: u64 = 4 * 1_024 * 1_024;
272
273struct Metered<'a> {
284 bytes: Cursor<&'a [u8]>,
285 left: Rc<Cell<u64>>,
286}
287
288impl Read for Metered<'_> {
289 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
290 let left = self.left.get();
291 if left == 0 {
292 return Err(std::io::Error::other("the search for a directory is over"));
293 }
294 let take = usize::try_from(left).map_or(buf.len(), |left| left.min(buf.len()));
295 let read = self.bytes.read(&mut buf[..take])?;
296 self.left.set(left.saturating_sub((read as u64).max(1)));
298 Ok(read)
299 }
300}
301
302impl Seek for Metered<'_> {
303 fn seek(&mut self, to: SeekFrom) -> std::io::Result<u64> {
304 self.bytes.seek(to)
305 }
306}
307
308struct Package<'a> {
312 ids: Vec<String>,
313 archive: RefCell<zip::ZipArchive<Metered<'a>>>,
314 names: HashMap<String, usize>,
316 unpacked: Cell<u64>,
318}
319
320impl<'a> Package<'a> {
321 fn read(bytes: &'a [u8]) -> Result<Self, ExtensionError> {
322 check_directory(bytes)?;
323 let left = Rc::new(Cell::new(
324 MAX_DIRECTORY_SEARCH.saturating_add((bytes.len() as u64).saturating_mul(2)),
325 ));
326 let metered = Metered {
327 bytes: Cursor::new(bytes),
328 left: Rc::clone(&left),
329 };
330 let archive = zip::ZipArchive::new(metered).map_err(|error| {
331 if left.get() == 0 {
332 ExtensionError::Malformed(
333 "dotLottie: no end of central directory record describes a directory".into(),
334 )
335 } else {
336 ExtensionError::Malformed(format!("dotLottie: {error}"))
337 }
338 })?;
339 left.set(u64::MAX);
341 if archive.len() > MAX_DOTLOTTIE_FILES {
342 return Err(ExtensionError::Budget(format!(
343 "the dotLottie package holds more than {MAX_DOTLOTTIE_FILES} files"
344 )));
345 }
346 let mut names = HashMap::new();
347 for (index, name) in archive.file_names().enumerate() {
348 let name =
349 name.map_err(|error| ExtensionError::Malformed(format!("dotLottie: {error}")))?;
350 if !name.ends_with('/') {
351 names.insert(name.trim_start_matches('/').to_owned(), index);
352 }
353 }
354 let package = Self {
355 ids: Vec::new(),
356 archive: RefCell::new(archive),
357 names,
358 unpacked: Cell::new(0),
359 };
360 let manifest: Value = serde_json::from_slice(
361 &package
362 .file("manifest.json", Some(MAX_LOTTIE_DOCUMENT_BYTES))?
363 .ok_or_else(|| ExtensionError::Malformed("dotLottie: no manifest.json".into()))?,
364 )
365 .map_err(|error| ExtensionError::Malformed(format!("dotLottie: {error}")))?;
366 let ids = manifest
367 .get("animations")
368 .and_then(Value::as_array)
369 .map(|animations| {
370 animations
371 .iter()
372 .filter_map(|animation| animation.get("id")?.as_str().map(str::to_owned))
373 .collect::<Vec<_>>()
374 })
375 .unwrap_or_default();
376 if ids.is_empty() {
377 return Err(ExtensionError::Malformed(
378 "dotLottie: the manifest lists no animation".into(),
379 ));
380 }
381 Ok(Self { ids, ..package })
382 }
383
384 fn file(&self, name: &str, document: Option<usize>) -> Result<Option<Vec<u8>>, ExtensionError> {
394 let Some(&index) = self.names.get(name) else {
395 return Ok(None);
396 };
397 let malformed = |error: &dyn std::fmt::Display| {
398 ExtensionError::Malformed(format!("dotLottie: {error}"))
399 };
400 let total = self.unpacked.get();
401 let left = MAX_LOTTIE_BYTES - total;
402 let (within, too_large) = match document {
403 Some(most) if (most as u64) < left => (
404 most as u64,
405 format!(
406 "the dotLottie package's {name} unpacks to more than the {most} bytes a \
407 provider reads"
408 ),
409 ),
410 _ => (left, "the dotLottie package unpacks past 256 MB".to_owned()),
411 };
412 let too_large = || ExtensionError::Budget(too_large.clone());
413 let mut archive = self.archive.borrow_mut();
414 let mut file = archive.by_index(index).map_err(|error| malformed(&error))?;
415 if file.size() > within {
416 return Err(too_large());
417 }
418 let mut contents = Vec::new();
419 let read = file
420 .by_ref()
421 .take(within + 1)
422 .read_to_end(&mut contents)
423 .map_err(|error| malformed(&error))? as u64;
424 if read > within {
425 return Err(too_large());
426 }
427 self.unpacked.set(total + read);
428 Ok(Some(contents))
429 }
430
431 fn animation(&self, id: Option<&str>) -> Result<Value, ExtensionError> {
434 let id = match id {
435 Some(id) if self.ids.iter().any(|known| known == id) => id,
436 Some(id) => {
437 return Err(ExtensionError::Mismatch(format!(
438 "the dotLottie package has no animation {id}"
439 )));
440 }
441 None => &self.ids[0],
442 };
443 let bytes = self
444 .first_file(
445 &[format!("a/{id}.json"), format!("animations/{id}.json")],
446 Some(MAX_LOTTIE_DOCUMENT_BYTES),
447 )?
448 .ok_or_else(|| {
449 ExtensionError::Malformed(format!("dotLottie: animation {id} has no file"))
450 })?;
451 parse_lottie(&bytes).ok_or_else(|| {
452 ExtensionError::Malformed(format!("dotLottie: animation {id} is not Lottie"))
453 })
454 }
455
456 fn probe(&self) -> Result<ProbeReport, ExtensionError> {
460 let mut unreadable = Vec::new();
461 let mut first = None;
462 for id in &self.ids {
463 match self
464 .animation(Some(id))
465 .and_then(|json| inspect(json, Format::DotLottie))
466 {
467 Ok((report, _)) => {
468 unreadable.extend(report.diagnostics);
469 return Ok(ProbeReport {
470 items: self.ids.clone(),
471 item: Some(id.clone()),
472 diagnostics: unreadable,
473 ..report
474 });
475 }
476 Err(error) => {
477 unreadable.push(Diagnostic::UnreadableItem {
478 item: id.clone(),
479 reason: error.to_string(),
480 });
481 first.get_or_insert(error);
482 }
483 }
484 }
485 Err(first.unwrap_or_else(|| {
486 ExtensionError::Malformed("dotLottie: the manifest lists no animation".into())
487 }))
488 }
489
490 fn image(&self, name: &str) -> Result<Option<Vec<u8>>, ExtensionError> {
493 let name = name.trim_start_matches('/');
494 self.first_file(
495 &[
496 name.to_owned(),
497 format!("i/{name}"),
498 format!("images/{name}"),
499 ],
500 None,
501 )
502 }
503
504 fn first_file(
507 &self,
508 paths: &[String],
509 document: Option<usize>,
510 ) -> Result<Option<Vec<u8>>, ExtensionError> {
511 for path in paths {
512 if let Some(bytes) = self.file(path, document)? {
513 return Ok(Some(bytes));
514 }
515 }
516 Ok(None)
517 }
518}
519
520fn inspect(json: Value, format: Format) -> Result<(ProbeReport, Value), ExtensionError> {
523 let root = json
524 .as_object()
525 .ok_or_else(|| ExtensionError::Malformed("the animation is not an object".into()))?;
526 let number = |key: &str| root.get(key).and_then(Value::as_f64).unwrap_or(0.0);
527 let (width, height) = (number("w"), number("h"));
528 let (rate, first, last) = (number("fr"), number("ip"), number("op"));
529 if !(width >= 1.0 && height >= 1.0 && rate > 0.0 && last > first)
530 || last - first > MAX_LOTTIE_FRAMES
531 {
532 return Err(ExtensionError::Malformed(
533 "the animation has no canvas, rate, or frames".into(),
534 ));
535 }
536 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
537 let (width, height, frames) = (
538 width.round() as u32,
539 height.round() as u32,
540 (last - first).ceil() as usize,
541 );
542 check_canvas(width, height)?;
543 let (diagnostics, sanitized) = normalize::normalize(json)?;
544 Ok((
545 ProbeReport {
546 format,
547 width,
548 height,
549 timing: Timing::Rate {
550 rate: rational_rate(rate).ok_or_else(|| {
551 ExtensionError::Malformed(format!("the frame rate {rate} is not usable"))
552 })?,
553 frames,
554 },
555 loop_count: None,
556 items: Vec::new(),
557 item: None,
558 color: None,
559 diagnostics,
560 },
561 sanitized,
562 ))
563}
564
565fn rational_rate(rate: f64) -> Option<FrameRate> {
568 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
569 let (num, den) = [24_u32, 30, 48, 60, 120]
570 .into_iter()
571 .find(|base| (rate - f64::from(*base) * 1_000.0 / 1_001.0).abs() < 0.005)
572 .map(|base| (base * 1_000, 1_001))
573 .or_else(|| ((rate - rate.round()).abs() < 0.005).then(|| (rate.round() as u32, 1)))
574 .unwrap_or_else(|| ((rate * 1_000.0).round() as u32, 1_000));
575 FrameRate::new(num, den).ok()
576}
577
578fn image_bytes(
582 asset: &ImageAsset,
583 package: Option<&Package>,
584 resolver: &dyn ResourceResolver,
585) -> Result<Option<Vec<u8>>, ExtensionError> {
586 if let Some(data) = asset.file_name.strip_prefix("data:") {
587 return Ok(data.split_once(";base64,").and_then(|(_, encoded)| {
588 base64::engine::general_purpose::STANDARD
589 .decode(encoded)
590 .ok()
591 }));
592 }
593 let name = format!(
594 "{}{}",
595 asset.directory.clone().unwrap_or_default(),
596 asset.file_name
597 );
598 if let Some(package) = package {
599 for candidate in [&name, &asset.file_name] {
600 if let Some(bytes) = package.image(candidate)? {
601 return Ok(Some(bytes));
602 }
603 }
604 }
605 Ok(resolver.bytes(&name).ok().map(|bytes| bytes.to_vec()))
606}
607
608fn decode_image(bytes: &[u8]) -> Option<Pixmap> {
611 let (width, height, mut pixels) = crate::raster::decode_png(bytes).ok()?;
612 crate::api::premultiply(&mut pixels);
613 Pixmap::from_vec(pixels, IntSize::from_wh(width, height)?)
614}
615
616fn short(name: &str) -> String {
617 if name.starts_with("data:") {
618 "an embedded image".into()
619 } else {
620 name.to_owned()
621 }
622}
623
624struct LottieSource {
626 composition: Composition,
627 renderer: Renderer,
628 images: HashMap<String, Pixmap>,
629 report: ProbeReport,
630 last: Option<VisualFrame>,
631}
632
633impl VisualSource for LottieSource {
634 fn report(&self) -> &ProbeReport {
635 &self.report
636 }
637
638 fn render(&mut self, time: MediaTime, fit: (u32, u32)) -> Result<VisualFrame, ExtensionError> {
639 let (index, start, end) = self.report.timing.frame_at(time);
640 let (width, height) = fitted(self.report.width, self.report.height, fit);
641 if let Some(last) = self
642 .last
643 .as_ref()
644 .filter(|last| last.index == index && (last.width, last.height) == (width, height))
645 {
646 return Ok(last.clone());
647 }
648 let mut sink = SkiaSink::new(width, height, &self.images)?;
649 #[allow(clippy::cast_precision_loss)]
650 let frame = self.composition.frames.start + index as f64;
651 let transform = Affine::scale_non_uniform(
652 f64::from(width) / f64::from(self.report.width),
653 f64::from(height) / f64::from(self.report.height),
654 );
655 self.renderer
656 .append(&self.composition, frame, transform, 1.0, &mut sink);
657 let frame = VisualFrame {
658 width,
659 height,
660 pixels: FramePixels::Rgba8(sink.finish().into()),
661 index,
662 start,
663 end,
664 diagnostics: Vec::new(),
665 };
666 self.last = Some(frame.clone());
667 Ok(frame)
668 }
669
670 fn resident_bytes(&self) -> usize {
671 self.images
672 .values()
673 .map(|image| image.data().len())
674 .sum::<usize>()
675 + self.last.as_ref().map_or(0, |last| last.pixels.byte_len())
676 }
677}
678
679fn fitted(width: u32, height: u32, fit: (u32, u32)) -> (u32, u32) {
682 if fit.0 == 0 || fit.1 == 0 {
683 return (width, height);
684 }
685 let scale = (f64::from(fit.0) / f64::from(width)).min(f64::from(fit.1) / f64::from(height));
686 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
687 (
688 (f64::from(width) * scale)
689 .round()
690 .clamp(1.0, f64::from(fit.0)) as u32,
691 (f64::from(height) * scale)
692 .round()
693 .clamp(1.0, f64::from(fit.1)) as u32,
694 )
695}
696
697struct SkiaSink<'a> {
709 width: u32,
710 height: u32,
711 layers: Vec<Layer>,
712 images: &'a HashMap<String, Pixmap>,
713 covering: Option<Rc<Mask>>,
716 pool: Vec<Pixmap>,
718 #[cfg(test)]
720 whole: bool,
721 #[cfg(test)]
723 composited: u64,
724 #[cfg(test)]
726 allocated: usize,
727}
728
729struct Layer {
731 pixmap: Option<Pixmap>,
733 blend: BlendMode,
734 alpha: f32,
735 clip: Option<Rc<Mask>>,
736 bounds: Option<IntRect>,
739 dirty: Option<IntRect>,
741}
742
743impl<'a> SkiaSink<'a> {
744 fn new(
745 width: u32,
746 height: u32,
747 images: &'a HashMap<String, Pixmap>,
748 ) -> Result<Self, ExtensionError> {
749 let pixmap = Pixmap::new(width, height).ok_or_else(|| {
750 ExtensionError::Budget(format!("cannot rasterize at {width}x{height}"))
751 })?;
752 Ok(Self {
753 width,
754 height,
755 layers: vec![Layer {
756 pixmap: Some(pixmap),
757 blend: BlendMode::SourceOver,
758 alpha: 1.0,
759 clip: None,
760 bounds: IntRect::from_xywh(0, 0, width, height),
761 dirty: None,
762 }],
763 images,
764 covering: None,
765 pool: Vec::new(),
766 #[cfg(test)]
767 whole: false,
768 #[cfg(test)]
769 composited: 0,
770 #[cfg(test)]
771 allocated: 0,
772 })
773 }
774
775 fn finish(mut self) -> Vec<u8> {
776 while self.layers.len() > 1 {
777 self.pop_layer();
778 }
779 self.layers
780 .pop()
781 .and_then(|layer| layer.pixmap)
782 .map_or_else(Vec::new, Pixmap::take)
783 }
784
785 #[allow(clippy::unused_self)]
788 fn whole(&self) -> bool {
789 #[cfg(test)]
790 return self.whole;
791 #[cfg(not(test))]
792 false
793 }
794
795 fn target(&mut self) -> Option<&mut Layer> {
797 self.layers
798 .iter_mut()
799 .rev()
800 .find(|layer| layer.pixmap.is_some())
801 }
802
803 fn draw_on(&mut self, bounds: Option<IntRect>) -> &mut Pixmap {
806 let canvas = IntRect::from_xywh(0, 0, self.width, self.height);
807 let layer = self.target().expect("the canvas is a layer");
808 layer.dirty = match (layer.dirty, bounds.or(canvas)) {
809 (Some(dirty), Some(bounds)) => union(dirty, bounds),
810 (dirty, bounds) => dirty.or(bounds),
811 };
812 layer.pixmap.as_mut().expect("a layer with a pixmap")
813 }
814
815 fn reach(&self, bounds: tiny_skia::Rect, transform: Transform) -> Option<IntRect> {
818 let device = PathBuilder::from_rect(bounds)
819 .transform(transform)?
820 .bounds();
821 #[allow(clippy::cast_precision_loss)]
823 let (width, height) = (self.width as f32, self.height as f32);
824 #[allow(clippy::cast_possible_truncation)]
825 IntRect::from_ltrb(
826 device.left().floor().max(-2.0) as i32 - 2,
827 device.top().floor().max(-2.0) as i32 - 2,
828 device.right().ceil().min(width) as i32 + 2,
829 device.bottom().ceil().min(height) as i32 + 2,
830 )?
831 .intersect(&IntRect::from_xywh(0, 0, self.width, self.height)?)
832 }
833
834 fn push(&mut self, blend: BlendMode, alpha: f32, affine: Affine, shape: &impl Shape) {
835 let transform = skia_transform(affine);
836 let path = path_of(shape);
837 let canvas = IntRect::from_xywh(0, 0, self.width, self.height);
838 let device = path
840 .clone()
841 .map(|path| path.transform(transform).map(|path| path.bounds()));
842 #[allow(clippy::cast_precision_loss)]
844 let (width, height) = (self.width as f32, self.height as f32);
845 let clips_nothing = shape.as_rect().is_some()
846 && transform.kx == 0.0
847 && transform.ky == 0.0
848 && matches!(device, Some(Some(bounds)) if bounds.left() <= 0.0
849 && bounds.top() <= 0.0
850 && bounds.right() >= width
851 && bounds.bottom() >= height);
852 #[allow(clippy::float_cmp)]
853 let opaque = alpha == 1.0;
854 if clips_nothing
855 && blend == BlendMode::SourceOver
856 && opaque
857 && !self.whole()
858 && self.target().is_some_and(|layer| layer.dirty.is_none())
859 {
860 self.layers.push(Layer {
861 pixmap: None,
862 blend,
863 alpha,
864 clip: None,
865 bounds: None,
866 dirty: None,
867 });
868 return;
869 }
870 let bounds = match &path {
871 None => canvas,
873 _ if self.whole() => canvas,
874 Some(path) => self.reach(path.bounds(), transform),
876 };
877 let clip = if clips_nothing && !self.whole() {
878 if self.covering.is_none() {
879 self.covering = IntSize::from_wh(self.width, self.height)
880 .and_then(|size| {
881 let coverage = vec![255; size.width() as usize * size.height() as usize];
882 Mask::from_vec(coverage, size)
883 })
884 .map(Rc::new);
885 }
886 self.covering.clone()
887 } else {
888 path.and_then(|path| {
889 let mut mask = Mask::new(self.width, self.height)?;
890 if device::within(shape, affine, 0.0, (self.width, self.height)) {
891 mask.fill_path(&path, FillRule::Winding, true, transform);
892 } else if let Some(far) =
893 device::fill_path(shape, affine, (self.width, self.height))
894 {
895 mask.fill_path(&far, FillRule::Winding, true, Transform::identity());
896 }
897 Some(Rc::new(mask))
898 })
899 };
900 let pooled = if self.whole() { None } else { self.pool.pop() };
901 #[cfg(test)]
902 if pooled.is_none() {
903 self.allocated += 1;
904 }
905 let Some(pixmap) = pooled.or_else(|| Pixmap::new(self.width, self.height)) else {
906 return;
907 };
908 self.layers.push(Layer {
909 pixmap: Some(pixmap),
910 blend,
911 alpha,
912 clip,
913 bounds,
914 dirty: None,
915 });
916 }
917}
918
919impl SkiaSink<'_> {
920 fn fill_far(&mut self, shape: &impl Shape, transform: Affine, mut paint: Paint<'_>) {
924 let Some(path) = device::fill_path(shape, transform, (self.width, self.height)) else {
925 return;
926 };
927 paint.shader.transform(skia_transform(transform));
928 let bounds = self.reach(path.bounds(), Transform::identity());
929 self.draw_on(bounds).fill_path(
930 &path,
931 &paint,
932 FillRule::Winding,
933 Transform::identity(),
934 None,
935 );
936 }
937
938 fn composite(&mut self, pixmap: &Pixmap, layer: &Layer, bounds: IntRect) {
941 #[cfg(test)]
942 {
943 self.composited += u64::from(bounds.width()) * u64::from(bounds.height());
944 }
945 let paint = Paint {
946 shader: tiny_skia::Pattern::new(
947 pixmap.as_ref(),
948 SpreadMode::Pad,
949 tiny_skia::FilterQuality::Nearest,
950 layer.alpha,
951 Transform::identity(),
952 ),
953 blend_mode: layer.blend,
954 anti_alias: false,
955 ..Paint::default()
956 };
957 self.draw_on(Some(bounds)).fill_rect(
958 bounds.to_rect(),
959 &paint,
960 Transform::identity(),
961 layer.clip.as_deref(),
962 );
963 }
964}
965
966fn union(a: IntRect, b: IntRect) -> Option<IntRect> {
968 IntRect::from_ltrb(
969 a.left().min(b.left()),
970 a.top().min(b.top()),
971 a.right().max(b.right()),
972 a.bottom().max(b.bottom()),
973 )
974}
975
976impl RenderSink for SkiaSink<'_> {
977 fn push_layer(
978 &mut self,
979 blend: impl Into<PenikoBlend>,
980 alpha: f32,
981 transform: Affine,
982 shape: &impl Shape,
983 ) {
984 self.push(blend_mode(blend.into()), alpha, transform, shape);
985 }
986
987 fn push_clip_layer(&mut self, transform: Affine, shape: &impl Shape) {
988 self.push(BlendMode::SourceOver, 1.0, transform, shape);
989 }
990
991 fn pop_layer(&mut self) {
992 if self.layers.len() < 2 {
993 return;
994 }
995 let Some(layer) = self.layers.pop() else {
996 return;
997 };
998 let mut layer = layer;
999 let Some(mut pixmap) = layer.pixmap.take() else {
1000 return;
1001 };
1002 let bounds = layer.bounds.and_then(|bounds| match layer.blend {
1005 BlendMode::SourceOver if !self.whole() => layer.dirty?.intersect(&bounds),
1006 _ => Some(bounds),
1007 });
1008 if let Some(bounds) = bounds {
1009 self.composite(&pixmap, &layer, bounds);
1010 }
1011 if let Some(dirty) = layer.dirty {
1013 let at = |value: i32| usize::try_from(value).unwrap_or(0);
1014 let stride = pixmap.width() as usize * 4;
1015 let (left, right) = (at(dirty.left()) * 4, at(dirty.right()) * 4);
1016 for row in pixmap
1017 .data_mut()
1018 .chunks_exact_mut(stride)
1019 .skip(at(dirty.top()))
1020 .take(dirty.height() as usize)
1021 {
1022 row[left..right].fill(0);
1023 }
1024 }
1025 self.pool.push(pixmap);
1026 }
1027 fn draw(
1028 &mut self,
1029 stroke: Option<&kurbo::Stroke>,
1030 transform: Affine,
1031 brush: &Brush,
1032 shape: &impl Shape,
1033 ) {
1034 let Some(path) = path_of(shape) else {
1035 return;
1036 };
1037 let Some(shader) = shader_of(brush) else {
1038 return;
1039 };
1040 let paint = Paint {
1041 shader,
1042 anti_alias: true,
1043 ..Paint::default()
1044 };
1045 let affine = transform;
1046 let transform = skia_transform(affine);
1047 match stroke {
1048 Some(stroke) if stroke.width.is_nan() || stroke.width <= 0.0 => {}
1051 Some(stroke) => {
1052 let style = Stroke {
1053 #[allow(clippy::cast_possible_truncation)]
1054 width: stroke.width as f32,
1055 #[allow(clippy::cast_possible_truncation)]
1056 miter_limit: stroke.miter_limit as f32,
1057 line_cap: match stroke.start_cap {
1058 kurbo::Cap::Butt => LineCap::Butt,
1059 kurbo::Cap::Round => LineCap::Round,
1060 kurbo::Cap::Square => LineCap::Square,
1061 },
1062 line_join: match stroke.join {
1063 kurbo::Join::Bevel => LineJoin::Bevel,
1064 kurbo::Join::Miter => LineJoin::Miter,
1065 kurbo::Join::Round => LineJoin::Round,
1066 },
1067 #[allow(clippy::cast_possible_truncation)]
1068 dash: (!stroke.dash_pattern.is_empty())
1069 .then(|| {
1070 StrokeDash::new(
1071 stroke
1072 .dash_pattern
1073 .iter()
1074 .map(|value| *value as f32)
1075 .collect(),
1076 stroke.dash_offset as f32,
1077 )
1078 })
1079 .flatten(),
1080 };
1081 let reach = style.width / 2.0 * style.miter_limit.max(std::f32::consts::SQRT_2)
1085 + style.width;
1086 if !device::within(shape, affine, f64::from(reach), (self.width, self.height)) {
1087 let scale = PathStroker::compute_resolution_scale(&transform);
1090 let dashed = match &style.dash {
1091 Some(dash) => path.dash(dash, scale),
1092 None => Some(path),
1093 };
1094 if let Some(outline) = dashed.and_then(|path| path.stroke(&style, scale)) {
1095 self.fill_far(&device::kurbo_path(&outline), affine, paint);
1096 }
1097 return;
1098 }
1099 let bounds = tiny_skia::Rect::from_ltrb(
1100 path.bounds().left() - reach,
1101 path.bounds().top() - reach,
1102 path.bounds().right() + reach,
1103 path.bounds().bottom() + reach,
1104 )
1105 .and_then(|bounds| self.reach(bounds, transform));
1106 self.draw_on(bounds)
1107 .stroke_path(&path, &paint, &style, transform, None);
1108 }
1109 None if !device::within(shape, affine, 0.0, (self.width, self.height)) => {
1110 self.fill_far(shape, affine, paint);
1111 }
1112 None => {
1113 let bounds = self.reach(path.bounds(), transform);
1114 self.draw_on(bounds)
1115 .fill_path(&path, &paint, FillRule::Winding, transform, None);
1116 }
1117 }
1118 }
1119
1120 fn draw_image(&mut self, image: &ImageAsset, transform: Affine, alpha: f64) {
1121 let Some(pixmap) = self.images.get(&image.id) else {
1122 return;
1123 };
1124 let picture = (f64::from(pixmap.width()), f64::from(pixmap.height()));
1128 let transform = match image.width.zip(image.height) {
1129 Some((width, height)) if width > 0.0 && height > 0.0 && (width, height) != picture => {
1130 let scale = (width / picture.0).min(height / picture.1);
1131 transform
1132 * Affine::translate((
1133 (width - picture.0 * scale) / 2.0,
1134 (height - picture.1 * scale) / 2.0,
1135 ))
1136 * Affine::scale(scale)
1137 }
1138 _ => transform,
1139 };
1140 let paint = PixmapPaint {
1141 #[allow(clippy::cast_possible_truncation)]
1142 opacity: alpha as f32,
1143 blend_mode: BlendMode::SourceOver,
1144 quality: tiny_skia::FilterQuality::Bilinear,
1145 };
1146 let picture = kurbo::Rect::new(0.0, 0.0, picture.0, picture.1);
1147 if !device::within(&picture, transform, 0.0, (self.width, self.height)) {
1148 let paint = Paint {
1150 shader: tiny_skia::Pattern::new(
1151 pixmap.as_ref(),
1152 SpreadMode::Pad,
1153 paint.quality,
1154 paint.opacity,
1155 Transform::identity(),
1156 ),
1157 blend_mode: paint.blend_mode,
1158 anti_alias: false,
1159 ..Paint::default()
1160 };
1161 self.fill_far(&picture, transform, paint);
1162 return;
1163 }
1164 let transform = skia_transform(transform);
1165 let bounds = IntRect::from_xywh(0, 0, pixmap.width(), pixmap.height())
1166 .and_then(|picture| self.reach(picture.to_rect(), transform));
1167 self.draw_on(bounds)
1168 .draw_pixmap(0, 0, pixmap.as_ref(), &paint, transform, None);
1169 }
1170}
1171
1172fn skia_transform(affine: Affine) -> Transform {
1173 #[allow(clippy::cast_possible_truncation)]
1174 let [sx, ky, kx, sy, tx, ty] = affine.as_coeffs().map(|value| value as f32);
1175 Transform::from_row(sx, ky, kx, sy, tx, ty)
1176}
1177
1178fn path_of(shape: &impl Shape) -> Option<tiny_skia::Path> {
1180 let mut builder = PathBuilder::new();
1181 #[allow(clippy::cast_possible_truncation)]
1182 let point = |p: kurbo::Point| (p.x as f32, p.y as f32);
1183 for element in shape.path_elements(0.1) {
1184 match element {
1185 PathEl::MoveTo(p) => {
1186 let (x, y) = point(p);
1187 builder.move_to(x, y);
1188 }
1189 PathEl::LineTo(p) => {
1190 let (x, y) = point(p);
1191 builder.line_to(x, y);
1192 }
1193 PathEl::QuadTo(p1, p2) => {
1194 let ((x1, y1), (x, y)) = (point(p1), point(p2));
1195 builder.quad_to(x1, y1, x, y);
1196 }
1197 PathEl::CurveTo(p1, p2, p3) => {
1198 let ((x1, y1), (x2, y2), (x, y)) = (point(p1), point(p2), point(p3));
1199 builder.cubic_to(x1, y1, x2, y2, x, y);
1200 }
1201 PathEl::ClosePath => builder.close(),
1202 }
1203 }
1204 builder.finish()
1205}
1206
1207fn shader_of(brush: &Brush) -> Option<Shader<'static>> {
1208 let color = |components: [f32; 4]| {
1209 Color::from_rgba(
1210 components[0].clamp(0.0, 1.0),
1211 components[1].clamp(0.0, 1.0),
1212 components[2].clamp(0.0, 1.0),
1213 components[3].clamp(0.0, 1.0),
1214 )
1215 };
1216 match brush {
1217 Brush::Solid(solid) => color(solid.components).map(Shader::SolidColor),
1218 Brush::Gradient(gradient) => {
1219 let stops = gradient
1220 .stops
1221 .iter()
1222 .filter_map(|stop| {
1223 let srgb = stop.color.to_alpha_color::<peniko::color::Srgb>();
1224 color(srgb.components).map(|c| GradientStop::new(stop.offset, c))
1225 })
1226 .collect::<Vec<_>>();
1227 let mode = match gradient.extend {
1228 Extend::Pad => SpreadMode::Pad,
1229 Extend::Repeat => SpreadMode::Repeat,
1230 Extend::Reflect => SpreadMode::Reflect,
1231 };
1232 #[allow(clippy::cast_possible_truncation)]
1233 let point = |p: kurbo::Point| Point::from_xy(p.x as f32, p.y as f32);
1234 match &gradient.kind {
1235 GradientKind::Linear(linear) => LinearGradient::new(
1236 point(linear.start),
1237 point(linear.end),
1238 stops,
1239 mode,
1240 Transform::identity(),
1241 ),
1242 GradientKind::Radial(radial) => RadialGradient::new(
1243 point(radial.start_center),
1244 radial.start_radius,
1245 point(radial.end_center),
1246 radial.end_radius,
1247 stops,
1248 mode,
1249 Transform::identity(),
1250 ),
1251 GradientKind::Sweep(sweep) => SweepGradient::new(
1252 point(sweep.center),
1253 sweep.start_angle.to_degrees(),
1254 sweep.end_angle.to_degrees(),
1255 stops,
1256 mode,
1257 Transform::identity(),
1258 ),
1259 }
1260 }
1261 Brush::Image(_) => None,
1262 }
1263}
1264
1265fn blend_mode(blend: PenikoBlend) -> BlendMode {
1268 match blend.mix {
1269 Mix::Multiply => BlendMode::Multiply,
1270 Mix::Screen => BlendMode::Screen,
1271 Mix::Overlay => BlendMode::Overlay,
1272 Mix::Darken => BlendMode::Darken,
1273 Mix::Lighten => BlendMode::Lighten,
1274 Mix::ColorDodge => BlendMode::ColorDodge,
1275 Mix::ColorBurn => BlendMode::ColorBurn,
1276 Mix::HardLight => BlendMode::HardLight,
1277 Mix::SoftLight => BlendMode::SoftLight,
1278 Mix::Difference => BlendMode::Difference,
1279 Mix::Exclusion => BlendMode::Exclusion,
1280 Mix::Hue => BlendMode::Hue,
1281 Mix::Saturation => BlendMode::Saturation,
1282 Mix::Color => BlendMode::Color,
1283 Mix::Luminosity => BlendMode::Luminosity,
1284 Mix::Normal => match blend.compose {
1285 Compose::Clear => BlendMode::Clear,
1286 Compose::Copy => BlendMode::Source,
1287 Compose::Dest => BlendMode::Destination,
1288 Compose::DestOver => BlendMode::DestinationOver,
1289 Compose::SrcIn => BlendMode::SourceIn,
1290 Compose::DestIn => BlendMode::DestinationIn,
1291 Compose::SrcOut => BlendMode::SourceOut,
1292 Compose::DestOut => BlendMode::DestinationOut,
1293 Compose::SrcAtop => BlendMode::SourceAtop,
1294 Compose::DestAtop => BlendMode::DestinationAtop,
1295 Compose::Xor => BlendMode::Xor,
1296 Compose::Plus | Compose::PlusLighter => BlendMode::Plus,
1297 Compose::SrcOver => BlendMode::SourceOver,
1298 },
1299 }
1300}
1301
1302#[cfg(test)]
1303mod tests;