1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum MaskFormat {
30 Json,
32 Svg,
34 Png,
36 Pdf,
38}
39
40pub 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 '&' => "&",
340 '"' => """,
341 '<' => "<",
342 '>' => ">",
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}