Skip to content

Commit c9f3aec

Browse files
committed
og_image: Add oxipng-based PNG compression to reduce file sizes
1 parent 947d741 commit c9f3aec

6 files changed

+117
-3
lines changed

.github/workflows/ci.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ env:
2323
CARGO_DENY_VERSION: 0.18.3
2424
# renovate: datasource=crate depName=cargo-machete versioning=semver
2525
CARGO_MACHETE_VERSION: 0.8.0
26+
# renovate: datasource=github-releases depName=shssoichiro/oxipng versioning=semver
27+
OXIPNG_VERSION: 9.1.5
2628
# renovate: datasource=npm depName=pnpm
2729
PNPM_VERSION: 10.12.4
2830
# renovate: datasource=docker depName=postgres
@@ -182,6 +184,14 @@ jobs:
182184
rm -rf "typst-x86_64-unknown-linux-musl" "typst-x86_64-unknown-linux-musl.tar.xz"
183185
typst --version
184186
187+
- name: Install oxipng
188+
run: |
189+
wget -q "https://github.com/shssoichiro/oxipng/releases/download/v${OXIPNG_VERSION}/oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl.tar.gz"
190+
tar -xf "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl.tar.gz"
191+
sudo mv "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl/oxipng" /usr/local/bin/
192+
rm -rf "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl" "oxipng-${OXIPNG_VERSION}-x86_64-unknown-linux-musl.tar.gz"
193+
oxipng --version
194+
185195
- name: Download Fira Sans font
186196
run: |
187197
wget -q "https://github.com/mozilla/Fira/archive/4.202.zip"

crates/crates_io_og_image/src/lib.rs

Lines changed: 107 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use bytes::Bytes;
1010
use crates_io_env_vars::var;
1111
use serde::Serialize;
1212
use std::collections::HashMap;
13-
use std::path::PathBuf;
13+
use std::path::{Path, PathBuf};
1414
use tempfile::NamedTempFile;
1515
use tokio::fs;
1616
use tokio::process::Command;
@@ -76,6 +76,7 @@ impl<'a> OgImageAuthorData<'a> {
7676
pub struct OgImageGenerator {
7777
typst_binary_path: PathBuf,
7878
typst_font_path: Option<PathBuf>,
79+
oxipng_binary_path: PathBuf,
7980
}
8081

8182
impl OgImageGenerator {
@@ -93,6 +94,7 @@ impl OgImageGenerator {
9394
Self {
9495
typst_binary_path,
9596
typst_font_path: None,
97+
oxipng_binary_path: PathBuf::from("oxipng"),
9698
}
9799
}
98100

@@ -113,6 +115,7 @@ impl OgImageGenerator {
113115
pub fn from_environment() -> Result<Self, OgImageError> {
114116
let typst_path = var("TYPST_PATH").map_err(OgImageError::EnvVarError)?;
115117
let font_path = var("TYPST_FONT_PATH").map_err(OgImageError::EnvVarError)?;
118+
let oxipng_path = var("OXIPNG_PATH").map_err(OgImageError::EnvVarError)?;
116119

117120
let mut generator = if let Some(ref path) = typst_path {
118121
debug!(typst_path = %path, "Using custom Typst binary path from environment");
@@ -132,6 +135,15 @@ impl OgImageGenerator {
132135
debug!("No custom font path specified, using Typst default font discovery");
133136
}
134137

138+
let oxipng_binary_path = if let Some(ref path) = oxipng_path {
139+
debug!(oxipng_path = %path, "Using custom oxipng binary path from environment");
140+
PathBuf::from(path)
141+
} else {
142+
debug!("OXIPNG_PATH not set, defaulting to 'oxipng' in PATH");
143+
PathBuf::from("oxipng")
144+
};
145+
generator.oxipng_binary_path = oxipng_binary_path;
146+
135147
Ok(generator)
136148
}
137149

@@ -156,6 +168,25 @@ impl OgImageGenerator {
156168
self
157169
}
158170

171+
/// Sets the oxipng binary path for PNG optimization.
172+
///
173+
/// This allows specifying a custom path to the oxipng binary for PNG optimization.
174+
/// If not set, defaults to "oxipng" which assumes the binary is available in PATH.
175+
///
176+
/// # Examples
177+
///
178+
/// ```
179+
/// use std::path::PathBuf;
180+
/// use crates_io_og_image::OgImageGenerator;
181+
///
182+
/// let generator = OgImageGenerator::default()
183+
/// .with_oxipng_path(PathBuf::from("/usr/local/bin/oxipng"));
184+
/// ```
185+
pub fn with_oxipng_path(mut self, oxipng_path: PathBuf) -> Self {
186+
self.oxipng_binary_path = oxipng_path;
187+
self
188+
}
189+
159190
/// Processes avatars by downloading URLs and copying assets to the assets directory.
160191
///
161192
/// This method handles both asset-based avatars (which are copied from the bundled assets)
@@ -165,7 +196,7 @@ impl OgImageGenerator {
165196
async fn process_avatars<'a>(
166197
&self,
167198
data: &'a OgImageData<'_>,
168-
assets_dir: &std::path::Path,
199+
assets_dir: &Path,
169200
) -> Result<HashMap<&'a str, String>, OgImageError> {
170201
let mut avatar_map = HashMap::new();
171202

@@ -408,20 +439,93 @@ impl OgImageGenerator {
408439
output_size_bytes, "Typst compilation completed successfully"
409440
);
410441

442+
// After successful Typst compilation, optimize the PNG
443+
self.optimize_png(output_file.path()).await;
444+
411445
let duration = start_time.elapsed();
412446
info!(
413447
duration_ms = duration.as_millis(),
414448
output_size_bytes, "OpenGraph image generation completed successfully"
415449
);
416450
Ok(output_file)
417451
}
452+
453+
/// Optimizes a PNG file using oxipng.
454+
///
455+
/// This method attempts to reduce the file size of a PNG using lossless compression.
456+
/// All errors are handled internally and logged as warnings. The method never fails
457+
/// to ensure PNG optimization is truly optional.
458+
async fn optimize_png(&self, png_file: &Path) {
459+
debug!(
460+
input_file = %png_file.display(),
461+
oxipng_path = %self.oxipng_binary_path.display(),
462+
"Starting PNG optimization"
463+
);
464+
465+
let start_time = std::time::Instant::now();
466+
467+
let mut command = Command::new(&self.oxipng_binary_path);
468+
469+
// Default optimization level for speed/compression balance
470+
command.arg("--opt").arg("2");
471+
472+
// Remove safe-to-remove metadata
473+
command.arg("--strip").arg("safe");
474+
475+
// Overwrite the input PNG file
476+
command.arg(png_file);
477+
478+
// Clear environment variables to avoid leaking sensitive data
479+
command.env_clear();
480+
481+
// Preserve environment variables needed for running oxipng
482+
if let Ok(path) = std::env::var("PATH") {
483+
command.env("PATH", path);
484+
}
485+
486+
let output = command.output().await;
487+
let duration = start_time.elapsed();
488+
489+
match output {
490+
Ok(output) if output.status.success() => {
491+
debug!(
492+
duration_ms = duration.as_millis(),
493+
"PNG optimization completed successfully"
494+
);
495+
}
496+
Ok(output) => {
497+
let stderr = String::from_utf8_lossy(&output.stderr);
498+
let stdout = String::from_utf8_lossy(&output.stdout);
499+
warn!(
500+
exit_code = ?output.status.code(),
501+
stderr = %stderr,
502+
stdout = %stdout,
503+
duration_ms = duration.as_millis(),
504+
input_file = %png_file.display(),
505+
"PNG optimization failed, continuing with unoptimized image"
506+
);
507+
}
508+
Err(err) => {
509+
warn!(
510+
error = %err,
511+
input_file = %png_file.display(),
512+
oxipng_path = %self.oxipng_binary_path.display(),
513+
"Failed to execute oxipng, continuing with unoptimized image"
514+
);
515+
}
516+
}
517+
}
418518
}
419519

420520
impl Default for OgImageGenerator {
421521
/// Creates a default `OgImageGenerator` that assumes the Typst binary is available
422522
/// as "typst" in the system PATH.
423523
fn default() -> Self {
424-
Self::new(PathBuf::from("typst"))
524+
Self {
525+
typst_binary_path: PathBuf::from("typst"),
526+
typst_font_path: None,
527+
oxipng_binary_path: PathBuf::from("oxipng"),
528+
}
425529
}
426530
}
427531

Loading

0 commit comments

Comments
 (0)