Skip to main content

appcore_filemaker/
debug_export.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: debug_export.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded debug export contracts and behavior for this crate.
12
13use std::fmt;
14use std::io::{sink, Write};
15
16use pdf_writer::writers::{Catalog, DocumentInfo};
17use pdf_writer::{Finish, Rect as PdfRect, Ref, TextStr};
18use serde::{Deserialize, Serialize};
19use tiny_skia::{Paint, Pixmap, Rect as TinyRect, Transform};
20
21use crate::export::bounded_string::{
22    output_limit_error, CountingOutput, FormattedOutput, StreamingOutput,
23};
24use crate::{CollisionMask, ErrorCode, FileMakerError, ResourceLimits, Result, Unit};
25
26/// Collision-mask output representation.
27#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum MaskFormat {
30    /// Stable geometry JSON.
31    Json,
32    /// Vector SVG.
33    Svg,
34    /// Raster PNG.
35    Png,
36    /// Vector PDF.
37    Pdf,
38}
39
40/// Exports a derived mask without inspecting rendered pixels.
41pub fn export_collision_mask(
42    mask: &CollisionMask,
43    format: MaskFormat,
44    dpi: u32,
45    limits: &ResourceLimits,
46    writer: &mut dyn Write,
47) -> Result<usize> {
48    limits.validate()?;
49    mask.validate_limits(limits)?;
50    match format {
51        MaskFormat::Json => json(mask, limits, writer),
52        MaskFormat::Svg => svg(mask, limits, writer),
53        MaskFormat::Png => png(mask, dpi, limits, writer),
54        MaskFormat::Pdf => pdf(mask, limits, writer),
55    }
56}
57
58fn json(mask: &CollisionMask, limits: &ResourceLimits, writer: &mut dyn Write) -> Result<usize> {
59    let size = crate::memory::serialized_size_pretty(mask)?;
60    if size > limits.max_output_bytes {
61        return Err(FileMakerError::new(
62            ErrorCode::LimitExceeded,
63            "debug mask JSON exceeds the output budget",
64        ));
65    }
66    serde_json::to_writer_pretty(writer, mask)
67        .map_err(|error| FileMakerError::new(ErrorCode::ExportWrite, error.to_string()))?;
68    Ok(size)
69}
70
71fn svg(mask: &CollisionMask, limits: &ResourceLimits, writer: &mut dyn Write) -> Result<usize> {
72    let mut counter = CountingOutput::new(limits.max_output_bytes);
73    write_svg(mask, &mut counter)?;
74    let size = counter.finish()?;
75    let mut output = StreamingOutput::new(writer, limits.max_output_bytes);
76    write_svg(mask, &mut output)?;
77    let written = output.finish()?;
78    debug_assert_eq!(written, size);
79    Ok(written)
80}
81
82fn write_svg(mask: &CollisionMask, output: &mut impl FormattedOutput) -> Result<()> {
83    write!(
84        output,
85        "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {} {}\">",
86        points(mask.size.width),
87        points(mask.size.height)
88    )
89    .map_err(format_error)?;
90    for (id, bounds) in &mask.occupied {
91        output.push_str("<rect data-element=\"")?;
92        write_escaped(output, id.as_str())?;
93        write!(
94            output,
95            "\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"#ff000033\" stroke=\"#cc0000\"/>",
96            points(bounds.origin.x),
97            points(bounds.origin.y),
98            points(bounds.size.width),
99            points(bounds.size.height)
100        )
101        .map_err(format_error)?;
102    }
103    for (_, _, bounds) in &mask.collisions {
104        write!(
105            output,
106            "<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"#ff00ff99\"/>",
107            points(bounds.origin.x),
108            points(bounds.origin.y),
109            points(bounds.size.width),
110            points(bounds.size.height)
111        )
112        .map_err(format_error)?;
113    }
114    output.push_str("</svg>")
115}
116
117fn png(
118    mask: &CollisionMask,
119    dpi: u32,
120    limits: &ResourceLimits,
121    writer: &mut dyn Write,
122) -> Result<usize> {
123    if dpi == 0 || dpi > 9_600 {
124        return Err(mask_error("mask DPI must be between 1 and 9600"));
125    }
126    let scale = f64::from(dpi) / 72.0;
127    let width = pixels(mask.size.width, scale)?;
128    let height = pixels(mask.size.height, scale)?;
129    if u64::from(width) * u64::from(height) > limits.max_pixels {
130        return Err(FileMakerError::new(
131            ErrorCode::LimitExceeded,
132            "mask pixel count exceeds configured limit",
133        ));
134    }
135    let tile_rows = crate::export::bounded_tile_rows(width)?;
136    let occupied = paint(0.8, 0.0, 0.0, 0.22)?;
137    let collision = paint(1.0, 0.0, 1.0, 0.6)?;
138    crate::export::encode_png_tiled(
139        width,
140        height,
141        tile_rows,
142        limits.max_output_bytes,
143        writer,
144        &mut |top, rows| {
145            let mut pixmap = Pixmap::new(width, rows).ok_or_else(|| {
146                FileMakerError::new(ErrorCode::LimitExceeded, "cannot allocate mask strip")
147            })?;
148            pixmap.fill(tiny_skia::Color::WHITE);
149            for (_, bounds) in &mask.occupied {
150                fill_rect(&mut pixmap, *bounds, scale, top, &occupied)?;
151            }
152            for (_, _, bounds) in &mask.collisions {
153                fill_rect(&mut pixmap, *bounds, scale, top, &collision)?;
154            }
155            Ok(pixmap)
156        },
157    )
158}
159
160fn pdf(mask: &CollisionMask, limits: &ResourceLimits, writer: &mut dyn Write) -> Result<usize> {
161    let mut counter = CountingOutput::new(limits.max_output_bytes);
162    write_pdf_content(mask, &mut counter)?;
163    let content_size = counter.finish()?;
164    let expected = write_pdf(mask, content_size, limits, &mut sink())?;
165    let written = write_pdf(mask, content_size, limits, writer)?;
166    if written != expected {
167        return Err(FileMakerError::new(
168            ErrorCode::Validation,
169            "debug PDF changed between sizing and streaming",
170        ));
171    }
172    Ok(written)
173}
174
175fn write_pdf(
176    mask: &CollisionMask,
177    content_size: usize,
178    limits: &ResourceLimits,
179    writer: &mut dyn Write,
180) -> Result<usize> {
181    let catalog = Ref::new(1);
182    let info = Ref::new(2);
183    let tree = Ref::new(3);
184    let page = Ref::new(4);
185    let stream = Ref::new(5);
186    let mut pdf = crate::export::pdf_stream::PdfDocument::new(
187        writer,
188        limits.max_output_bytes,
189        catalog,
190        info,
191    )?;
192    let width = points(mask.size.width);
193    let height = points(mask.size.height);
194    pdf.object(catalog, |chunk| {
195        let mut catalog_writer: Catalog<'_> = chunk.indirect(catalog).start();
196        catalog_writer.pages(tree);
197        Ok(())
198    })?;
199    pdf.object(info, |chunk| {
200        let mut info_writer: DocumentInfo<'_> = chunk.indirect(info).start();
201        info_writer
202            .title(TextStr("AppCore FileMaker collision mask"))
203            .producer(TextStr(concat!(
204                "appcore-filemaker ",
205                env!("CARGO_PKG_VERSION")
206            )));
207        Ok(())
208    })?;
209    pdf.object(tree, |chunk| {
210        chunk.pages(tree).kids([page]).count(1);
211        Ok(())
212    })?;
213    pdf.object(page, |chunk| {
214        let mut page_writer = chunk.page(page);
215        page_writer
216            .media_box(PdfRect::new(0.0, 0.0, width, height))
217            .parent(tree)
218            .contents(stream);
219        page_writer.finish();
220        Ok(())
221    })?;
222    pdf.stream_object(stream, content_size, |writer| {
223        let mut output = StreamingOutput::new(writer, content_size);
224        write_pdf_content(mask, &mut output)?;
225        let written = output.finish()?;
226        if written != content_size {
227            return Err(FileMakerError::new(
228                ErrorCode::Validation,
229                "debug PDF content changed after sizing",
230            ));
231        }
232        Ok(())
233    })?;
234    pdf.finish()
235}
236
237fn write_pdf_content(mask: &CollisionMask, output: &mut impl FormattedOutput) -> Result<()> {
238    output.push_str("0.8 0 0 rg\n")?;
239    for (_, bounds) in &mask.occupied {
240        write_pdf_rect(output, mask.size.height, *bounds)?;
241    }
242    output.push_str("1 0 1 rg\n")?;
243    for (_, _, bounds) in &mask.collisions {
244        write_pdf_rect(output, mask.size.height, *bounds)?;
245    }
246    Ok(())
247}
248
249fn write_pdf_rect(
250    output: &mut impl FormattedOutput,
251    page_height: Unit,
252    bounds: crate::Rect,
253) -> Result<()> {
254    let y = page_height
255        .checked_sub(bounds.origin.y)?
256        .checked_sub(bounds.size.height)?;
257    write!(
258        output,
259        "{} {} {} {} re\nf\n",
260        PdfUnit(bounds.origin.x),
261        PdfUnit(y),
262        PdfUnit(bounds.size.width),
263        PdfUnit(bounds.size.height),
264    )
265    .map_err(format_error)
266}
267
268struct PdfUnit(Unit);
269
270impl fmt::Display for PdfUnit {
271    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272        let raw = i128::from(self.0.raw());
273        if raw < 0 {
274            formatter.write_str("-")?;
275        }
276        let absolute = raw.abs();
277        let whole = absolute / i128::from(Unit::PER_POINT);
278        let mut fraction = absolute % i128::from(Unit::PER_POINT);
279        write!(formatter, "{whole}")?;
280        if fraction == 0 {
281            return Ok(());
282        }
283        let mut width = 6;
284        while fraction % 10 == 0 {
285            fraction /= 10;
286            width -= 1;
287        }
288        write!(formatter, ".{fraction:0width$}")
289    }
290}
291
292fn fill_rect(
293    pixmap: &mut Pixmap,
294    bounds: crate::Rect,
295    scale: f64,
296    strip_top: u32,
297    paint: &Paint<'_>,
298) -> Result<()> {
299    let rect = TinyRect::from_xywh(
300        points(bounds.origin.x) * scale as f32,
301        points(bounds.origin.y) * scale as f32,
302        points(bounds.size.width) * scale as f32,
303        points(bounds.size.height) * scale as f32,
304    )
305    .ok_or_else(|| mask_error("mask rectangle is invalid"))?;
306    pixmap.fill_rect(
307        rect,
308        paint,
309        Transform::from_translate(0.0, -(strip_top as f32)),
310        None,
311    );
312    Ok(())
313}
314
315fn paint(r: f32, g: f32, b: f32, a: f32) -> Result<Paint<'static>> {
316    let mut paint = Paint::default();
317    let color = tiny_skia::Color::from_rgba(r, g, b, a)
318        .ok_or_else(|| mask_error("mask paint is invalid"))?;
319    paint.set_color(color);
320    Ok(paint)
321}
322
323fn pixels(value: Unit, scale: f64) -> Result<u32> {
324    let pixels = (value.as_points_f64() * scale).ceil();
325    if !pixels.is_finite() || pixels <= 0.0 || pixels > f64::from(u32::MAX) {
326        return Err(mask_error("mask pixel dimension is invalid"));
327    }
328    Ok(pixels as u32)
329}
330
331fn points(value: Unit) -> f32 {
332    value.as_points_f64() as f32
333}
334
335fn write_escaped(output: &mut impl FormattedOutput, value: &str) -> Result<()> {
336    let mut start = 0;
337    for (index, character) in value.char_indices() {
338        let replacement = match character {
339            '&' => "&amp;",
340            '"' => "&quot;",
341            '<' => "&lt;",
342            '>' => "&gt;",
343            _ => continue,
344        };
345        output.push_str(&value[start..index])?;
346        output.push_str(replacement)?;
347        start = index + character.len_utf8();
348    }
349    output.push_str(&value[start..])
350}
351
352fn format_error(_: std::fmt::Error) -> FileMakerError {
353    output_limit_error()
354}
355
356fn mask_error(message: impl Into<String>) -> FileMakerError {
357    FileMakerError::new(ErrorCode::ExportWrite, message)
358}
359
360#[cfg(test)]
361mod tests {
362    use super::PdfUnit;
363    use crate::Unit;
364
365    #[test]
366    fn pdf_units_are_exact_non_exponential_fixed_point_numbers() {
367        for (raw, expected) in [
368            (0, "0"),
369            (1, "0.000001"),
370            (1_230_000, "1.23"),
371            (-250_000, "-0.25"),
372            (i64::MIN, "-9223372036854.775808"),
373        ] {
374            assert_eq!(PdfUnit(Unit::from_raw(raw)).to_string(), expected);
375        }
376    }
377}