Skip to main content

oxideav_cli_convert/
ping.rs

1//! `-ping` runner — print one IM-format header line per "image" and
2//! exit without decoding pixels or writing any output.
3//!
4//! Output line shape (matches `imagemagick identify` / `convert -ping
5//! input info:`):
6//!
7//! ```text
8//! <path> <FORMAT> <W>x<H> <W>x<H>+0+0 <DEPTH>-bit <COLORSPACE> <BYTES>B
9//! ```
10//!
11//! For multi-page inputs (PDF) one line per selected page is emitted
12//! with the path suffixed `[N]`. For container inputs we emit one line
13//! per video stream (skipping audio-only files with a friendly note).
14
15use std::fs;
16
17use oxideav_core::{Error, PixelFormat, Result, RuntimeContext, SourceOutput};
18use oxideav_pdf::read_pdf_to_scene;
19
20use crate::op::ConvertPlan;
21use crate::pdf_runner::is_pdf_input;
22
23/// Print IM-format ping lines for `plan.input` to stdout.
24pub fn run(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<()> {
25    if is_pdf_input(&plan.input) {
26        return ping_pdf(plan);
27    }
28    ping_container(plan, ctx)
29}
30
31fn ping_pdf(plan: &ConvertPlan) -> Result<()> {
32    let bytes = fs::read(&plan.input)
33        .map_err(|e| Error::invalid(format!("convert: failed to read {}: {e}", plan.input)))?;
34    let file_size = bytes.len();
35    let scene = read_pdf_to_scene(&bytes)
36        .map_err(|e| Error::invalid(format!("convert: failed to parse PDF: {e:?}")))?;
37    let pages = scene.pages.as_deref().unwrap_or(&[]);
38    if pages.is_empty() {
39        return Err(Error::invalid("convert: PDF has no pages to ping"));
40    }
41    let indices: Vec<usize> = match &plan.input_pages {
42        Some(sel) => sel
43            .resolve(pages.len())
44            .map_err(|e| Error::invalid(format!("convert: input '{}': {e}", plan.input)))?,
45        None => (0..pages.len()).collect(),
46    };
47    for idx in indices {
48        let page = &pages[idx];
49        // PDF Page width/height come in PostScript points (1/72 inch).
50        // IM reports them as integer pixels for `-ping` (no rasterisation
51        // happens; the geometry is the page's intrinsic point size).
52        let w = page.width.round() as u32;
53        let h = page.height.round() as u32;
54        println!(
55            "{}[{}] PDF {w}x{h} {w}x{h}+0+0 8-bit sRGB {}B",
56            plan.input, idx, file_size
57        );
58    }
59    Ok(())
60}
61
62fn ping_container(plan: &ConvertPlan, ctx: &RuntimeContext) -> Result<()> {
63    let raw = match ctx.sources.open(&plan.input)? {
64        SourceOutput::Bytes(b) => b,
65        SourceOutput::Packets(_) => {
66            return Err(Error::unsupported(format!(
67                "convert -ping: {}: packet-shape source not supported",
68                plan.input
69            )));
70        }
71        SourceOutput::Frames(_) => {
72            return Err(Error::unsupported(format!(
73                "convert -ping: {}: frame-shape source not supported",
74                plan.input
75            )));
76        }
77        // Multi-title sources (BD-ROM, DVD-Video, multi-edition MKV)
78        // need a per-title fan-out the convert verb doesn't model;
79        // `-ping` is single-stream by design.
80        SourceOutput::MultiTitle(_) => {
81            return Err(Error::unsupported(format!(
82                "convert -ping: {}: multi-title sources are not supported",
83                plan.input
84            )));
85        }
86        // Any future `#[non_exhaustive]`-gated variant — the local
87        // umbrella's `oxideav-core` enables non_exhaustive but the
88        // currently-published crate doesn't, so the arm appears
89        // unreachable on CI. The allow keeps both builds green.
90        #[allow(unreachable_patterns)]
91        _ => {
92            return Err(Error::unsupported(format!(
93                "convert -ping: {}: source kind not supported by ping",
94                plan.input
95            )));
96        }
97    };
98    let file_size = fs::metadata(&plan.input).map(|m| m.len()).unwrap_or(0);
99    // BytesSource is Read+Seek+Send; the demuxer just needs ReadSeek.
100    let mut handle: Box<dyn oxideav_core::ReadSeek> = Box::new(raw);
101    let ext = ext_from_uri(&plan.input);
102    let format = ctx
103        .containers
104        .probe_input(&mut *handle, ext.as_deref())
105        .map_err(|e| Error::invalid(format!("convert -ping: {}: {e}", plan.input)))?;
106    let demuxer = ctx
107        .containers
108        .open_demuxer(&format, handle, &ctx.codecs)
109        .map_err(|e| Error::invalid(format!("convert -ping: {}: {e}", plan.input)))?;
110
111    let format_label = format_label_for(&format, demuxer.format_name());
112    let video_streams: Vec<_> = demuxer
113        .streams()
114        .iter()
115        .filter(|s| matches!(s.params.media_type, oxideav_core::MediaType::Video))
116        .collect();
117
118    if video_streams.is_empty() {
119        // Audio-only / data-only container: emit a 0x0 line so callers
120        // don't get silent failures, and a stderr note for humans.
121        eprintln!(
122            "convert -ping: {}: no video stream — emitting 0x0 line",
123            plan.input
124        );
125        println!("{} {format_label} 0x0 0x0+0+0 - {file_size}B", plan.input);
126        return Ok(());
127    }
128
129    for (i, s) in video_streams.iter().enumerate() {
130        let p = &s.params;
131        let w = p.width.unwrap_or(0);
132        let h = p.height.unwrap_or(0);
133        let (depth, colorspace) = match p.pixel_format {
134            Some(pf) => describe_pixel_format(pf),
135            None => (0, "Unknown"),
136        };
137        let depth_field = if depth == 0 {
138            String::from("?-bit")
139        } else {
140            format!("{depth}-bit")
141        };
142        // Single-stream files (the common case for PNG/JPG) get the
143        // bare path; multi-stream files disambiguate with [N].
144        let label = if video_streams.len() == 1 {
145            plan.input.clone()
146        } else {
147            format!("{}[{i}]", plan.input)
148        };
149        println!(
150            "{label} {format_label} {w}x{h} {w}x{h}+0+0 {depth_field} {colorspace} {file_size}B"
151        );
152    }
153    Ok(())
154}
155
156/// Map a `PixelFormat` to (bit depth per channel, IM-style colorspace
157/// label). The depth refers to the per-channel bit width as IM reports
158/// it; colorspace is `sRGB` / `Gray` / `CMYK` / `Unknown`.
159fn describe_pixel_format(pf: PixelFormat) -> (u8, &'static str) {
160    use PixelFormat::*;
161    match pf {
162        // 8-bit sRGB (RGB / RGBA / BGR / BGRA / ARGB / ABGR)
163        Rgb24 | Rgba | Bgr24 | Bgra | Argb | Abgr => (8, "sRGB"),
164        // 16-bit packed RGB / RGBA
165        Rgb48Le | Rgba64Le => (16, "sRGB"),
166        // YUV planar / packed at 8 / 10 / 12 — IM treats YUV as sRGB.
167        Yuv420P | Yuv422P | Yuv444P | YuvJ420P | YuvJ422P | YuvJ444P | Nv12 | Nv21 | Yuyv422
168        | Uyvy422 | Yuva420P => (8, "sRGB"),
169        Yuv420P10Le | Yuv422P10Le | Yuv444P10Le => (10, "sRGB"),
170        Yuv420P12Le | Yuv422P12Le | Yuv444P12Le => (12, "sRGB"),
171        // Grayscale family
172        Gray8 | Ya8 => (8, "Gray"),
173        Gray10Le => (10, "Gray"),
174        Gray12Le => (12, "Gray"),
175        Gray16Le => (16, "Gray"),
176        MonoBlack | MonoWhite => (1, "Gray"),
177        // Palette
178        Pal8 => (8, "sRGB"),
179        // CMYK (prepress)
180        Cmyk => (8, "CMYK"),
181        // Anything we don't have a clean depth/colorspace for.
182        _ => (0, "Unknown"),
183    }
184}
185
186/// Extract a friendly IM-style format label.
187///
188/// `format_name` is whatever the demuxer reports (typically already the
189/// container's preferred shorthand). `format` is the registry-key form.
190/// We prefer `format_name` upper-cased; if it's empty fall back to the
191/// registry key.
192fn format_label_for(format: &str, format_name: &str) -> String {
193    let chosen = if !format_name.is_empty() {
194        format_name
195    } else {
196        format
197    };
198    chosen.to_ascii_uppercase()
199}
200
201/// Best-effort extension hint from a URI: takes everything after the
202/// last `/`-segment's `.`, ignoring `?…` query strings. Mirrors the
203/// helper in `oxideav-cli` (kept local to avoid a new dep).
204fn ext_from_uri(uri: &str) -> Option<String> {
205    let last = uri.rsplit('/').next().unwrap_or(uri);
206    let last = last.split('?').next().unwrap_or(last);
207    let dot = last.rfind('.')?;
208    Some(last[dot + 1..].to_ascii_lowercase())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn format_label_uppercase() {
217        assert_eq!(format_label_for("png", "png"), "PNG");
218        assert_eq!(format_label_for("png", ""), "PNG");
219        assert_eq!(format_label_for("matroska", "MKV"), "MKV");
220    }
221
222    #[test]
223    fn ext_hint() {
224        assert_eq!(ext_from_uri("/tmp/a.png").as_deref(), Some("png"));
225        assert_eq!(
226            ext_from_uri("https://x/y.JPG?cache=1").as_deref(),
227            Some("jpg")
228        );
229        assert_eq!(ext_from_uri("noext").as_deref(), None);
230    }
231
232    #[test]
233    fn pixel_format_mapping_covers_common_cases() {
234        assert_eq!(describe_pixel_format(PixelFormat::Rgb24), (8, "sRGB"));
235        assert_eq!(describe_pixel_format(PixelFormat::Rgba64Le), (16, "sRGB"));
236        assert_eq!(describe_pixel_format(PixelFormat::Gray8), (8, "Gray"));
237        assert_eq!(describe_pixel_format(PixelFormat::Gray16Le), (16, "Gray"));
238        assert_eq!(
239            describe_pixel_format(PixelFormat::Yuv420P10Le),
240            (10, "sRGB")
241        );
242        assert_eq!(describe_pixel_format(PixelFormat::Cmyk), (8, "CMYK"));
243        assert_eq!(describe_pixel_format(PixelFormat::MonoBlack), (1, "Gray"));
244    }
245}