diff --git a/Cargo.lock b/Cargo.lock index 1daf96a..f9e7c1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3275,6 +3275,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "skrifa", "svgtypes", "swc_common", "swc_css", diff --git a/Cargo.toml b/Cargo.toml index 86e2b5f..3b78d90 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ resvg = { version = "0.48.1", default-features = false, features = [ "raster-images", "text", ] } +skrifa = "0.44.0" saphyr = "0.0.6" sha1_smol = "1.0" slab = "0.4.12" diff --git a/crates/zensical/Cargo.toml b/crates/zensical/Cargo.toml index 7a79fde..bbf3690 100644 --- a/crates/zensical/Cargo.toml +++ b/crates/zensical/Cargo.toml @@ -74,6 +74,7 @@ percent-encoding.workspace = true pyo3.workspace = true regex.workspace = true resvg.workspace = true +skrifa.workspace = true saphyr.workspace = true serde = { workspace = true, features = ["derive", "rc"] } serde_json.workspace = true diff --git a/crates/zensical/src/compat/mkdocs/plugin/social/render.rs b/crates/zensical/src/compat/mkdocs/plugin/social/render.rs index 3c8d549..57926a4 100644 --- a/crates/zensical/src/compat/mkdocs/plugin/social/render.rs +++ b/crates/zensical/src/compat/mkdocs/plugin/social/render.rs @@ -9,10 +9,10 @@ // rights to use, copy, modify, merge, publish, distribute, sublicense, and/or // sell copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: -// + // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. -// + // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE @@ -32,6 +32,10 @@ use resvg::tiny_skia::{Pixmap, PixmapPaint, Transform}; use resvg::usvg; use serde::Serialize; use sha2::{Digest, Sha256}; +use skrifa::{ + instance::{LocationRef, Size}, + MetadataProvider, +}; use std::collections::{BTreeMap, HashMap}; use std::fmt::Write as _; use std::fs; @@ -62,18 +66,27 @@ pub struct Tag { /// Immutable renderer shared by concurrent card jobs. #[derive(Clone, Debug)] pub struct Renderer { + /// Project settings used by card templates and asset resolution. project: std::sync::Arc, + /// Theme directories searched for icons. theme_dirs: std::sync::Arc<[PathBuf]>, + /// Resolver for selected font faces. fonts: Fonts, + /// Known revisions of untracked image and icon dependencies. dependencies: Arc>>, } +/// Generated image properties exposed to metadata templates. #[derive(Serialize)] struct ImageContext<'a> { + /// Absolute URL of the generated card. url: &'a str, + /// Media type of the generated card. #[serde(rename = "type")] kind: &'static str, + /// Card width in pixels. width: u32, + /// Card height in pixels. height: u32, } @@ -155,12 +168,12 @@ impl Renderer { ); } if let Some((color, grid, step)) = debug { - let svg = debug_svg(layout, color, grid, step)?; let fonts = self.fonts.load(&Font { family: "Roboto".into(), variant: String::new(), style: "Regular".into(), })?; + let svg = debug_svg(layout, color, grid, step, Some(&fonts))?; let overlay = render_svg( &svg, layout.size.width, @@ -211,6 +224,7 @@ impl Renderer { .collect() } + /// Rasterizes one layer with its image, icon, and typography. fn layer(&self, layer: &Layer) -> Result { let fonts = (!layer.typography.content.is_empty()) .then(|| self.fonts.load(&layer.typography.font)) @@ -234,6 +248,7 @@ impl Renderer { render_svg(&svg, layer.size.width, layer.size.height, fonts.as_ref()) } + /// Encodes a background image as a data URL. fn background(&self, value: &str) -> Result { let path = self.background_path(value); data_url(&path).with_context(|| { @@ -241,6 +256,7 @@ impl Renderer { }) } + /// Resolves a background image relative to the project root. fn background_path(&self, value: &str) -> PathBuf { let path = Path::new(value); if path.is_absolute() { @@ -250,6 +266,7 @@ impl Renderer { } } + /// Encodes a theme icon as a colorized SVG data URL. fn icon(&self, name: &str, color: &str) -> Result { let mut data = self.icon_source(name)?; if !color.is_empty() { @@ -261,11 +278,13 @@ impl Renderer { )) } + /// Reads an icon from the selected theme directory. fn icon_source(&self, name: &str) -> Result { let path = self.icon_path(name)?; Ok(fs::read_to_string(path)?) } + /// Finds a named icon in the available theme directories. fn icon_path(&self, name: &str) -> Result { for base in self.theme_dirs.iter() { let path = base.join(".icons").join(format!("{name}.svg")); @@ -279,6 +298,7 @@ impl Renderer { Err(plugin_error(anyhow!("couldn't find icon '{name}'"))) } + /// Returns a watched or locally cached file revision. fn file_revision( &self, path: &Path, known: &BTreeMap, ) -> Result<[u8; 32]> { @@ -312,8 +332,15 @@ fn template_context( .as_object_mut() .context("serialized page must be a mapping")?; page_map.insert("is_homepage".into(), serde_json::Value::Bool(is_homepage)); + let mut config = serde_json::to_value(project)?; + if !project.theme.font_explicit { + config["theme"]["font"] = serde_json::Value::Null; + } + if !project.theme.icon.logo_explicit { + config["theme"]["icon"]["logo"] = serde_json::Value::Null; + } Ok(context! { - config => project, + config => config, page => page_value, layout => options, image => image, @@ -444,7 +471,7 @@ fn typography_svg( ) -> Result { let mut allowed = typography.line.amount; let mut size = - font_size(layer.size.height, allowed, typography.line.height); + font_size(layer.size.height, allowed, typography.line.height, fonts); let mut lines = wrap( &typography.content, f64::from(layer.size.width), @@ -455,8 +482,12 @@ fn typography_svg( if typography.overflow == "shrink" { while lines.len() > allowed { allowed += 1; - size = - font_size(layer.size.height, allowed, typography.line.height); + size = font_size( + layer.size.height, + allowed, + typography.line.height, + fonts, + ); lines = wrap( &typography.content, f64::from(layer.size.width), @@ -497,13 +528,17 @@ fn typography_svg( balance_two_lines(&mut lines); } - let line_height = size * typography.line.height; + let (ascender, _) = font_metrics(fonts); + let line_height = size * ascender / 1000.0 * typography.line.height; let additional = u32::try_from(lines.len().saturating_sub(1)) .map_or(f64::from(u32::MAX), f64::from); let height = size + line_height * additional; let (anchor, x) = horizontal(&typography.align, f64::from(layer.size.width)); - let y = vertical(&typography.align, f64::from(layer.size.height), height); + // Pillow's ascender anchor positions glyphs below the top of the line box. + // SVG's hanging baseline starts at the glyphs, so account for that inset. + let y = vertical(&typography.align, f64::from(layer.size.height), height) + + size * 0.2; let attributes = font_attributes(&typography.font); let mut spans = String::new(); for (index, line) in lines.iter().enumerate() { @@ -584,10 +619,30 @@ fn render_svg( Ok(pixmap) } -fn font_size(height: u32, lines: usize, line_height: f64) -> f64 { +fn font_size( + height: u32, lines: usize, line_height: f64, + fonts: Option<&Arc>, +) -> f64 { let lines = u32::try_from(lines).map_or(f64::from(u32::MAX), f64::from); - let extent = lines + 0.25 + (lines - 1.0) * (line_height - 1.0); - f64::from(height) / extent + let (ascender, descender) = font_metrics(fonts); + let extent = lines * ascender + + descender + + (lines - 1.0) * (line_height - 1.0) * ascender; + (1000.0 * f64::from(height) / extent).floor() +} + +fn font_metrics(fonts: Option<&Arc>) -> (f64, f64) { + fonts + .and_then(|database| database.faces().next().map(|face| face.id)) + .and_then(|id| { + fonts?.with_face_data(id, |data, index| { + let font = skrifa::FontRef::from_index(data, index).ok()?; + let metrics = + font.metrics(Size::new(1000.0), LocationRef::default()); + Some((f64::from(metrics.ascent), -f64::from(metrics.descent))) + })? + }) + .unwrap_or((1000.0, 250.0)) } fn font_attributes(font: &Font) -> String { @@ -715,6 +770,7 @@ fn colorize_icon(source: &str, color: &str) -> String { fn debug_svg( layout: &Layout, color: &str, grid: bool, step: usize, + fonts: Option<&Arc>, ) -> Result { let parsed = parse_color(color, "debug color")?; let label_color = if f64::from(parsed.red) * 0.299 @@ -732,7 +788,10 @@ fn debug_svg( for y in (0..layout.size.height).step_by(step) { write!( body, - "", + "", + i64::from(x) - 1, + xml_attribute(color), + i64::from(y) - 1, xml_attribute(color), ) .expect("writing to a string cannot fail"); @@ -741,24 +800,33 @@ fn debug_svg( } for (index, layer) in layout.layers.iter().enumerate() { let (x, y) = offset(layer, layout.size.width, layout.size.height); + let right = (i64::from(x) + i64::from(layer.size.width)) + .min(i64::from(layout.size.width) - 1); + let bottom = (i64::from(y) + i64::from(layer.size.height)) + .min(i64::from(layout.size.height) - 1); + let width = layer.size.width.saturating_add(1); + let height = layer.size.height.saturating_add(1); write!( body, - "", - layer.size.width, - layer.size.height, + "", xml_attribute(color), ) .expect("writing to a string cannot fail"); let label = format!("{index} – {x}, {y}"); - let width = 12_u32.saturating_mul( - u32::try_from(label.chars().count()).unwrap_or(u32::MAX), - ); + let font = Font { + family: "Roboto".into(), + variant: String::new(), + style: "Regular".into(), + }; + let width = measure(&label, 12.0, &font, fonts)?.ceil() + 9.0; + let (ascender, descender) = font_metrics(fonts); + let height = ((ascender + descender) * 12.0 / 1000.0).ceil() + 4.0; write!( body, - "{}", + "{}", xml_attribute(color), x.saturating_add(4), - y.saturating_add(3), + y.saturating_add(5), xml_text(&label), ) .expect("writing to a string cannot fail"); @@ -789,14 +857,15 @@ fn xml_attribute(value: &str) -> String { #[cfg(test)] mod tests { use base64::Engine as _; + use minijinja::Value; + + use crate::compat::mkdocs::plugin::social::layout::Size; use super::{ balance_two_lines, colorize_icon, debug_svg, environment, font_attributes, layer_svg, offset, render_svg, render_template, Font, Layer, Layout, }; - use minijinja::Value; - #[test] fn balances_two_lines_by_moving_one_word() { let mut lines = vec!["one two three".into(), "four".into()]; @@ -860,12 +929,12 @@ mod tests { fn debug_labels_contrast_with_the_overlay() { let layout = Layout { tags: Vec::new(), - size: super::super::layout::Size { width: 10, height: 10 }, + size: Size { width: 10, height: 10 }, layers: vec![Layer::default()], }; - assert!(debug_svg(&layout, "white", false, 1) + assert!(debug_svg(&layout, "white", false, 1, None) .unwrap() .contains("fill=\"black\"")); - assert!(debug_svg(&layout, "not-a-color", false, 1).is_err()); + assert!(debug_svg(&layout, "not-a-color", false, 1, None).is_err()); } } diff --git a/crates/zensical/src/config/theme.rs b/crates/zensical/src/config/theme.rs index 3bcda65..ec5f36e 100644 --- a/crates/zensical/src/config/theme.rs +++ b/crates/zensical/src/config/theme.rs @@ -67,6 +67,9 @@ pub struct Theme { pub features: Vec, /// Font settings. pub font: Font, + /// Whether the project explicitly configured its font settings. + #[serde(skip)] + pub font_explicit: bool, /// Static templates. pub static_templates: Vec, /// Favicon. @@ -103,6 +106,9 @@ pub struct Icon { pub view: Option, /// Logo icon. pub logo: Option, + /// Whether the project explicitly configured the logo icon. + #[serde(skip)] + pub logo_explicit: bool, /// Repository icon. pub repo: Option, /// Annotation icon. diff --git a/pyproject.toml b/pyproject.toml index c2c1e89..0e700d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ dev = [ "lxml>=6.1.0", "maturin>=1.10.2", "pandas>=2.3.3", + "pillow>=11.3.0", "pytest>=9.0.3", "ruff>=0.12.8", "tabulate>=0.10.0", diff --git a/python/tests/fixtures/social/README.md b/python/tests/fixtures/social/README.md new file mode 100644 index 0000000..6443174 --- /dev/null +++ b/python/tests/fixtures/social/README.md @@ -0,0 +1,44 @@ +# Social plugin compatibility matrix + +These projects compare the user-visible output of Material for MkDocs and +Zensical with the same `mkdocs.yml` and source files. Run the matrix from the +repository root: + +```console +/Users/squidfunk/.workspace/squidfunk/repos/mkdocs-material/community/venv/bin/python \ + scripts/social_compatibility.py \ + --mkdocs /Users/squidfunk/.workspace/squidfunk/repos/mkdocs-material/community/venv/bin/mkdocs \ + --zensical .venv/bin/zensical +``` + +| Case | Behavior | +| --- | --- | +| `basic` | Custom layout, root and nested card routes, page title and metadata | +| `filters` | Include precedence, exclude patterns, page-level opt-out | +| `metadata` | Meta plugin inheritance and page-level layout options | +| `multiple` | Ordered plugin instances, separate card directories and metadata | +| `no-site-url` | Cards generated without public image metadata | +| `image-only` | Bundled image-only layout and a local SVG dependency | +| `bundled` | Default, accent, invert and variant layouts with typography | +| `logo-icon` | Explicit theme logo icon and omitted font settings | +| `debug` | Build-time debug grid and color settings | +| `blog` | Cards for generated blog views and posts | + +The check compares generated card paths, dimensions and every card's mean RGB +pixel difference, plus each page's social metadata. A case passes when the +metadata and dimensions match and every card's mean RGB difference is at most +1 on a 0–255 scale. PNG file bytes may differ between image libraries even +when the visible output is the same. Build logs and full output are kept in a +temporary directory only while the command runs; pass `--output PATH` to keep +them for inspection. + +The bundled, logo-icon and debug cases download Roboto from Google Fonts on +their first build. +Use `--font-cache PATH` to seed both temporary projects from an existing Material +font cache and run it without a network connection. + +On 2026-09-23, all 10 cases matched using Material for MkDocs 9.7.1 with +Pillow 12.1.1 and this Zensical branch. The highest mean RGB difference was +0.566 (bundled layout); card paths, dimensions and social metadata matched in +every case. This is a compatibility sample, not a guarantee for every possible +layout or Material version. diff --git a/python/tests/fixtures/social/basic/docs/guide/advanced.md b/python/tests/fixtures/social/basic/docs/guide/advanced.md new file mode 100644 index 0000000..931fae0 --- /dev/null +++ b/python/tests/fixtures/social/basic/docs/guide/advanced.md @@ -0,0 +1 @@ +# Advanced diff --git a/python/tests/fixtures/social/basic/docs/guide/index.md b/python/tests/fixtures/social/basic/docs/guide/index.md new file mode 100644 index 0000000..8c0d02f --- /dev/null +++ b/python/tests/fixtures/social/basic/docs/guide/index.md @@ -0,0 +1 @@ +# Guide diff --git a/python/tests/fixtures/social/basic/docs/index.md b/python/tests/fixtures/social/basic/docs/index.md new file mode 100644 index 0000000..483e4cb --- /dev/null +++ b/python/tests/fixtures/social/basic/docs/index.md @@ -0,0 +1,6 @@ +--- +title: Home & Intro +description: A concise & useful description. +--- + +# Welcome diff --git a/python/tests/fixtures/social/basic/layouts/flat.yml b/python/tests/fixtures/social/basic/layouts/flat.yml new file mode 100644 index 0000000..ead527f --- /dev/null +++ b/python/tests/fixtures/social/basic/layouts/flat.yml @@ -0,0 +1,12 @@ +tags: + og:type: website + og:title: '{{ page.meta.get("title", page.title) }}' + og:description: '{{ page.meta.get("description", config.site_description) | x }}' + og:image: '{{ image.url }}' + og:image:width: '{{ image.width }}' + og:image:height: '{{ image.height }}' + og:url: '{{ page.canonical_url }}' + twitter:card: summary_large_image +size: { width: 320, height: 168 } +layers: + - background: { color: '#123456' } diff --git a/python/tests/fixtures/social/basic/mkdocs.yml b/python/tests/fixtures/social/basic/mkdocs.yml new file mode 100644 index 0000000..fca939e --- /dev/null +++ b/python/tests/fixtures/social/basic/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Social parity +site_description: A small parity site. +site_url: https://example.test/docs/ +site_dir: site +theme: + name: material +plugins: + - social: + cache: false + cards_layout: flat diff --git a/python/tests/fixtures/social/blog/docs/blog/index.md b/python/tests/fixtures/social/blog/docs/blog/index.md new file mode 100644 index 0000000..3d84c38 --- /dev/null +++ b/python/tests/fixtures/social/blog/docs/blog/index.md @@ -0,0 +1 @@ +# Journal diff --git a/python/tests/fixtures/social/blog/docs/blog/posts/first.md b/python/tests/fixtures/social/blog/docs/blog/posts/first.md new file mode 100644 index 0000000..6441b62 --- /dev/null +++ b/python/tests/fixtures/social/blog/docs/blog/posts/first.md @@ -0,0 +1,5 @@ +--- +date: 2026-09-20 +--- + +# First post diff --git a/python/tests/fixtures/social/blog/docs/blog/posts/second.md b/python/tests/fixtures/social/blog/docs/blog/posts/second.md new file mode 100644 index 0000000..dddb7d3 --- /dev/null +++ b/python/tests/fixtures/social/blog/docs/blog/posts/second.md @@ -0,0 +1,5 @@ +--- +date: 2026-09-21 +--- + +# Second post diff --git a/python/tests/fixtures/social/blog/docs/index.md b/python/tests/fixtures/social/blog/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/blog/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/blog/layouts/flat.yml b/python/tests/fixtures/social/blog/layouts/flat.yml new file mode 100644 index 0000000..1c2dfa9 --- /dev/null +++ b/python/tests/fixtures/social/blog/layouts/flat.yml @@ -0,0 +1,6 @@ +tags: + og:title: '{{ page.meta.get("title", page.title) }}' + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#123456' } diff --git a/python/tests/fixtures/social/blog/mkdocs.yml b/python/tests/fixtures/social/blog/mkdocs.yml new file mode 100644 index 0000000..2e8d9ff --- /dev/null +++ b/python/tests/fixtures/social/blog/mkdocs.yml @@ -0,0 +1,9 @@ +site_name: Social blog +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - blog + - social: + cache: false + cards_layout: flat diff --git a/python/tests/fixtures/social/bundled/docs/accent.md b/python/tests/fixtures/social/bundled/docs/accent.md new file mode 100644 index 0000000..54805de --- /dev/null +++ b/python/tests/fixtures/social/bundled/docs/accent.md @@ -0,0 +1,7 @@ +--- +title: An accent card +social: + cards_layout: default/accent +--- + +# Accent diff --git a/python/tests/fixtures/social/bundled/docs/index.md b/python/tests/fixtures/social/bundled/docs/index.md new file mode 100644 index 0000000..230c171 --- /dev/null +++ b/python/tests/fixtures/social/bundled/docs/index.md @@ -0,0 +1,6 @@ +--- +title: A thoughtful beginning +description: A concise introduction to the project. +--- + +# Home diff --git a/python/tests/fixtures/social/bundled/docs/invert.md b/python/tests/fixtures/social/bundled/docs/invert.md new file mode 100644 index 0000000..7dd6c69 --- /dev/null +++ b/python/tests/fixtures/social/bundled/docs/invert.md @@ -0,0 +1,7 @@ +--- +title: An inverted card +social: + cards_layout: default/invert +--- + +# Invert diff --git a/python/tests/fixtures/social/bundled/docs/variant.md b/python/tests/fixtures/social/bundled/docs/variant.md new file mode 100644 index 0000000..b419b90 --- /dev/null +++ b/python/tests/fixtures/social/bundled/docs/variant.md @@ -0,0 +1,7 @@ +--- +title: A variant card +social: + cards_layout: default/variant +--- + +# Variant diff --git a/python/tests/fixtures/social/bundled/mkdocs.yml b/python/tests/fixtures/social/bundled/mkdocs.yml new file mode 100644 index 0000000..0f8057b --- /dev/null +++ b/python/tests/fixtures/social/bundled/mkdocs.yml @@ -0,0 +1,19 @@ +site_name: Bundled cards +site_description: A site description for cards. +site_url: https://example.test/docs/ +site_dir: site +theme: + name: material + palette: + primary: deep purple + font: + text: Roboto +plugins: + - social: + cache: false + cache_dir: social-cache + cards_layout: default + cards_layout_options: + background_color: '#4c1d95' + color: '#ffffff' + font_family: Roboto diff --git a/python/tests/fixtures/social/debug/docs/index.md b/python/tests/fixtures/social/debug/docs/index.md new file mode 100644 index 0000000..fe1e66a --- /dev/null +++ b/python/tests/fixtures/social/debug/docs/index.md @@ -0,0 +1 @@ +# Debug view diff --git a/python/tests/fixtures/social/debug/layouts/flat.yml b/python/tests/fixtures/social/debug/layouts/flat.yml new file mode 100644 index 0000000..b4548cd --- /dev/null +++ b/python/tests/fixtures/social/debug/layouts/flat.yml @@ -0,0 +1,5 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#123456' } diff --git a/python/tests/fixtures/social/debug/mkdocs.yml b/python/tests/fixtures/social/debug/mkdocs.yml new file mode 100644 index 0000000..28de5e2 --- /dev/null +++ b/python/tests/fixtures/social/debug/mkdocs.yml @@ -0,0 +1,14 @@ +site_name: Debug cards +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cache_dir: social-cache + cards_layout: flat + debug: true + debug_on_build: true + debug_grid: true + debug_grid_step: 32 + debug_color: red diff --git a/python/tests/fixtures/social/filters/docs/guides/hidden.md b/python/tests/fixtures/social/filters/docs/guides/hidden.md new file mode 100644 index 0000000..bb943c8 --- /dev/null +++ b/python/tests/fixtures/social/filters/docs/guides/hidden.md @@ -0,0 +1 @@ +# Include wins over exclude diff --git a/python/tests/fixtures/social/filters/docs/guides/index.md b/python/tests/fixtures/social/filters/docs/guides/index.md new file mode 100644 index 0000000..d1a2daa --- /dev/null +++ b/python/tests/fixtures/social/filters/docs/guides/index.md @@ -0,0 +1 @@ +# Included guide diff --git a/python/tests/fixtures/social/filters/docs/guides/opt-out.md b/python/tests/fixtures/social/filters/docs/guides/opt-out.md new file mode 100644 index 0000000..16b5394 --- /dev/null +++ b/python/tests/fixtures/social/filters/docs/guides/opt-out.md @@ -0,0 +1,6 @@ +--- +social: + cards: false +--- + +# Page opt-out diff --git a/python/tests/fixtures/social/filters/docs/index.md b/python/tests/fixtures/social/filters/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/filters/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/filters/layouts/flat.yml b/python/tests/fixtures/social/filters/layouts/flat.yml new file mode 100644 index 0000000..bb7c269 --- /dev/null +++ b/python/tests/fixtures/social/filters/layouts/flat.yml @@ -0,0 +1,5 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#abcdef' } diff --git a/python/tests/fixtures/social/filters/mkdocs.yml b/python/tests/fixtures/social/filters/mkdocs.yml new file mode 100644 index 0000000..570c667 --- /dev/null +++ b/python/tests/fixtures/social/filters/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Social filters +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: flat + cards_include: ['guides/**'] + cards_exclude: ['guides/hidden.md'] diff --git a/python/tests/fixtures/social/image-only/docs/background.svg b/python/tests/fixtures/social/image-only/docs/background.svg new file mode 100644 index 0000000..e83454d --- /dev/null +++ b/python/tests/fixtures/social/image-only/docs/background.svg @@ -0,0 +1,3 @@ + + + diff --git a/python/tests/fixtures/social/image-only/docs/index.md b/python/tests/fixtures/social/image-only/docs/index.md new file mode 100644 index 0000000..98e600b --- /dev/null +++ b/python/tests/fixtures/social/image-only/docs/index.md @@ -0,0 +1 @@ +# Image card diff --git a/python/tests/fixtures/social/image-only/mkdocs.yml b/python/tests/fixtures/social/image-only/mkdocs.yml new file mode 100644 index 0000000..3758595 --- /dev/null +++ b/python/tests/fixtures/social/image-only/mkdocs.yml @@ -0,0 +1,10 @@ +site_name: Social image only +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: default/only/image + cards_layout_options: + background_image: docs/background.svg diff --git a/python/tests/fixtures/social/logo-icon/docs/index.md b/python/tests/fixtures/social/logo-icon/docs/index.md new file mode 100644 index 0000000..3552d38 --- /dev/null +++ b/python/tests/fixtures/social/logo-icon/docs/index.md @@ -0,0 +1,6 @@ +--- +title: A bright idea +description: A card with a logo icon. +--- + +# Home diff --git a/python/tests/fixtures/social/logo-icon/mkdocs.yml b/python/tests/fixtures/social/logo-icon/mkdocs.yml new file mode 100644 index 0000000..b33df16 --- /dev/null +++ b/python/tests/fixtures/social/logo-icon/mkdocs.yml @@ -0,0 +1,11 @@ +site_name: Logo icon cards +site_url: https://example.test/docs/ +site_dir: site +theme: + name: material + icon: + logo: material/star +plugins: + - social: + cache: false + cache_dir: social-cache diff --git a/python/tests/fixtures/social/metadata/docs/guides/.meta.yml b/python/tests/fixtures/social/metadata/docs/guides/.meta.yml new file mode 100644 index 0000000..01ff2f7 --- /dev/null +++ b/python/tests/fixtures/social/metadata/docs/guides/.meta.yml @@ -0,0 +1,4 @@ +social: + cards_layout_options: + background_color: '#445566' + label: inherited diff --git a/python/tests/fixtures/social/metadata/docs/guides/index.md b/python/tests/fixtures/social/metadata/docs/guides/index.md new file mode 100644 index 0000000..b0f52a5 --- /dev/null +++ b/python/tests/fixtures/social/metadata/docs/guides/index.md @@ -0,0 +1 @@ +# Inherited options diff --git a/python/tests/fixtures/social/metadata/docs/guides/override.md b/python/tests/fixtures/social/metadata/docs/guides/override.md new file mode 100644 index 0000000..68687f3 --- /dev/null +++ b/python/tests/fixtures/social/metadata/docs/guides/override.md @@ -0,0 +1,8 @@ +--- +social: + cards_layout_options: + background_color: '#778899' + label: page +--- + +# Page options diff --git a/python/tests/fixtures/social/metadata/docs/index.md b/python/tests/fixtures/social/metadata/docs/index.md new file mode 100644 index 0000000..f8d1ea4 --- /dev/null +++ b/python/tests/fixtures/social/metadata/docs/index.md @@ -0,0 +1 @@ +# Global options diff --git a/python/tests/fixtures/social/metadata/layouts/flat.yml b/python/tests/fixtures/social/metadata/layouts/flat.yml new file mode 100644 index 0000000..828d5cd --- /dev/null +++ b/python/tests/fixtures/social/metadata/layouts/flat.yml @@ -0,0 +1,6 @@ +tags: + og:image: '{{ image.url }}' + x:label: '{{ layout.label }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '{{ layout.background_color }}' } diff --git a/python/tests/fixtures/social/metadata/mkdocs.yml b/python/tests/fixtures/social/metadata/mkdocs.yml new file mode 100644 index 0000000..c6b336e --- /dev/null +++ b/python/tests/fixtures/social/metadata/mkdocs.yml @@ -0,0 +1,12 @@ +site_name: Social metadata +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - meta + - social: + cache: false + cards_layout: flat + cards_layout_options: + background_color: '#112233' + label: global diff --git a/python/tests/fixtures/social/multiple/docs/guide.md b/python/tests/fixtures/social/multiple/docs/guide.md new file mode 100644 index 0000000..8c0d02f --- /dev/null +++ b/python/tests/fixtures/social/multiple/docs/guide.md @@ -0,0 +1 @@ +# Guide diff --git a/python/tests/fixtures/social/multiple/docs/index.md b/python/tests/fixtures/social/multiple/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/multiple/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/multiple/layouts/flat.yml b/python/tests/fixtures/social/multiple/layouts/flat.yml new file mode 100644 index 0000000..8e35098 --- /dev/null +++ b/python/tests/fixtures/social/multiple/layouts/flat.yml @@ -0,0 +1,6 @@ +tags: + og:image: '{{ image.url }}' + x:instance: '{{ layout.label }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '{{ layout.color }}' } diff --git a/python/tests/fixtures/social/multiple/mkdocs.yml b/python/tests/fixtures/social/multiple/mkdocs.yml new file mode 100644 index 0000000..fb0170a --- /dev/null +++ b/python/tests/fixtures/social/multiple/mkdocs.yml @@ -0,0 +1,17 @@ +site_name: Social instances +site_url: https://example.test/docs/ +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: flat + cards_dir: assets/cards/first + cards_include: ['index.md'] + cards_layout_options: { label: first, color: '#102030' } + - social: + cache: false + cards_layout: flat + cards_dir: assets/cards/second + cards_include: ['guide.md'] + cards_layout_options: { label: second, color: '#405060' } diff --git a/python/tests/fixtures/social/no-site-url/docs/index.md b/python/tests/fixtures/social/no-site-url/docs/index.md new file mode 100644 index 0000000..291ca38 --- /dev/null +++ b/python/tests/fixtures/social/no-site-url/docs/index.md @@ -0,0 +1 @@ +# Home diff --git a/python/tests/fixtures/social/no-site-url/layouts/flat.yml b/python/tests/fixtures/social/no-site-url/layouts/flat.yml new file mode 100644 index 0000000..b4548cd --- /dev/null +++ b/python/tests/fixtures/social/no-site-url/layouts/flat.yml @@ -0,0 +1,5 @@ +tags: + og:image: '{{ image.url }}' +size: { width: 320, height: 168 } +layers: + - background: { color: '#123456' } diff --git a/python/tests/fixtures/social/no-site-url/mkdocs.yml b/python/tests/fixtures/social/no-site-url/mkdocs.yml new file mode 100644 index 0000000..6416702 --- /dev/null +++ b/python/tests/fixtures/social/no-site-url/mkdocs.yml @@ -0,0 +1,7 @@ +site_name: Social without URL +site_dir: site +theme: { name: material } +plugins: + - social: + cache: false + cards_layout: flat diff --git a/python/zensical/config.py b/python/zensical/config.py index 97f62a5..5f8cb6b 100644 --- a/python/zensical/config.py +++ b/python/zensical/config.py @@ -571,6 +571,13 @@ def _apply_defaults(config: dict, path: str) -> dict: elif "theme" not in config: config["theme"] = {} + font_explicit = "font" in config["theme"] + configured_icons = config["theme"].get("icon") + logo_icon_explicit = ( + isinstance(configured_icons, dict) + and configured_icons.get("logo") is not None + ) + # Set defaults for custom theme directory set_default(config["theme"], "custom_dir", None, str) @@ -600,6 +607,7 @@ def _apply_defaults(config: dict, path: str) -> dict: config["theme"] = {**theme_config, **config["theme"]} theme = config["theme"] + theme["font_explicit"] = font_explicit # Set defaults for theme name # (we do this after loading the theme configuration @@ -632,6 +640,7 @@ def _apply_defaults(config: dict, path: str) -> dict: # Set defaults for theme icons icon = set_default(theme, "icon", {}, dict) + icon["logo_explicit"] = logo_icon_explicit set_default(icon, "repo", None, str) set_default(icon, "annotation", None, str) set_default(icon, "tag", {}, dict) diff --git a/scripts/social_compatibility.py b/scripts/social_compatibility.py new file mode 100755 index 0000000..30aa0b8 --- /dev/null +++ b/scripts/social_compatibility.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python + +# Copyright (c) 2025-2026 Zensical and contributors + +# SPDX-License-Identifier: MIT +# All contributions are certified under the DCO + +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: + +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +# IN THE SOFTWARE. + +"""Compare generated Material and Zensical social cards and metadata.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +from bs4 import BeautifulSoup +from PIL import Image, ImageChops, ImageStat + +ROOT = Path(__file__).resolve().parents[1] +FIXTURES = ROOT / "python" / "tests" / "fixtures" / "social" + + +def arguments() -> argparse.Namespace: + """Parse builder paths and optional cases.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mkdocs", type=Path, required=True) + parser.add_argument("--zensical", type=Path, required=True) + parser.add_argument("--case", action="append", dest="cases") + parser.add_argument( + "--output", type=Path, help="keep builds in this empty directory" + ) + parser.add_argument( + "--font-cache", type=Path, help="existing social font cache" + ) + return parser.parse_args() + + +def build(executable: Path, project: Path, *, strict: bool) -> None: + """Build one copied fixture in its own project directory.""" + command = [ + str(executable), + "build", + "--clean", + "--config-file", + "mkdocs.yml", + ] + if strict: + command.append("--strict") + result = subprocess.run( + command, cwd=project, capture_output=True, text=True, check=False + ) + (project / "build.log").write_text( + f"$ {' '.join(command)}\n{result.stdout}{result.stderr}", + encoding="utf-8", + ) + if result.returncode: + raise RuntimeError( + f"build failed in {project} ({result.returncode}):\n" + f"{result.stdout}{result.stderr}" + ) + + +def manifest(site: Path) -> dict: + """Extract only social metadata and generated image facts.""" + pages = {} + for path in sorted(site.rglob("*.html")): + if path.name == "404.html": + continue + soup = BeautifulSoup(path.read_text(encoding="utf-8"), "lxml") + if soup.head is None: + raise ValueError(f"generated page lacks a head element: {path}") + tags = [ + [property_name, tag.get("content")] + for tag in soup.head.find_all("meta", property=True) + if isinstance(property_name := tag.get("property"), str) + and property_name.startswith(("og:", "twitter:", "x:")) + ] + pages[path.relative_to(site).as_posix()] = tags + + cards = {} + for path in sorted(site.rglob("*.png")): + relative = path.relative_to(site).as_posix() + if not relative.startswith(("assets/images/social/", "assets/cards/")): + continue + with Image.open(path) as image: + cards[relative] = list(image.size) + return {"pages": pages, "cards": cards} + + +def image_difference(first: Path, second: Path) -> float: + """Measure mean RGB channel difference independent of PNG encoding.""" + with Image.open(first) as left, Image.open(second) as right: + if left.size != right.size: + return float("inf") + difference = ImageChops.difference( + left.convert("RGB"), right.convert("RGB") + ) + return sum(ImageStat.Stat(difference).mean) / 3 + + +def compare(name: str, material: Path, zensical: Path) -> bool: + """Show semantic and visual differences for one fixture.""" + expected = manifest(material / "site") + actual = manifest(zensical / "site") + (material / "manifest.json").write_text( + json.dumps(expected, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + (zensical / "manifest.json").write_text( + json.dumps(actual, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + cards = set(expected["cards"]) | set(actual["cards"]) + differences = { + path: round( + image_difference( + material / "site" / path, zensical / "site" / path + ), + 3, + ) + for path in sorted(set(expected["cards"]) & set(actual["cards"])) + } + matched = expected == actual and all( + value <= 1 for value in differences.values() + ) + print( + f"{name}: {'MATCH' if matched else 'DIFF'}; " + f"{len(expected['pages'])} pages, {len(cards)} card paths; " + f"mean RGB error {max(differences.values(), default=0):.3f}" + ) + if expected != actual: + for key in ("pages", "cards"): + for path in sorted(set(expected[key]) | set(actual[key])): + if expected[key].get(path) != actual[key].get(path): + print(f" {key}/{path}") + print(f" Material: {expected[key].get(path)}") + print(f" Zensical: {actual[key].get(path)}") + for path, error in differences.items(): + if error > 1: + print(f" pixels/{path}: mean RGB error {error:.3f}") + return matched + + +def run(root: Path, args: argparse.Namespace) -> bool: + """Build selected fixtures in both engines and compare their outputs.""" + names = args.cases or sorted( + path.name + for path in FIXTURES.iterdir() + if (path / "mkdocs.yml").is_file() + ) + unknown = [ + name for name in names if not (FIXTURES / name / "mkdocs.yml").is_file() + ] + if unknown: + raise ValueError(f"unknown social cases: {', '.join(unknown)}") + matched = True + for name in names: + project = FIXTURES / name + material = root / name / "material" + zensical = root / name / "zensical" + shutil.copytree(project, material) + shutil.copytree(project, zensical) + if args.font_cache and name in {"bundled", "debug", "logo-icon"}: + for destination in (material, zensical): + shutil.copytree( + args.font_cache, + destination / "social-cache/fonts", + ) + strict = name not in {"no-site-url", "debug"} + build(args.mkdocs, material, strict=strict) + build(args.zensical, zensical, strict=strict) + matched &= compare(name, material, zensical) + return matched + + +def main() -> int: + """Run the selected compatibility matrix.""" + args = arguments() + args.mkdocs = args.mkdocs.resolve() + args.zensical = args.zensical.resolve() + if args.font_cache: + args.font_cache = args.font_cache.resolve() + if args.output: + root = args.output.resolve() + root.mkdir(parents=True, exist_ok=True) + if any(root.iterdir()): + raise ValueError(f"output directory must be empty: {root}") + print(f"Builds: {root}") + return 0 if run(root, args) else 1 + with tempfile.TemporaryDirectory(prefix="zensical-social-parity-") as raw: + return 0 if run(Path(raw), args) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index bd4adc9..b275b9f 100644 --- a/uv.lock +++ b/uv.lock @@ -616,6 +616,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -937,6 +1031,7 @@ dev = [ { name = "maturin" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pandas", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, { name = "pytest" }, { name = "ruff" }, { name = "tabulate" }, @@ -963,6 +1058,7 @@ dev = [ { name = "lxml", specifier = ">=6.1.0" }, { name = "maturin", specifier = ">=1.10.2" }, { name = "pandas", specifier = ">=2.3.3" }, + { name = "pillow", specifier = ">=11.3.0" }, { name = "pytest", specifier = ">=9.0.3" }, { name = "ruff", specifier = ">=0.12.8" }, { name = "tabulate", specifier = ">=0.10.0" },