1mod format;
25
26use std::collections::{BTreeMap, HashMap, HashSet};
27use std::fmt;
28
29use ravenrs_core::{Color, ImageData, Rect};
30use ravenrs_report::vector::{Shape, rasterize};
31use ravenrs_report::{Culture, Document, DocumentImage, Item, TextAlign, TextBlock};
32use rust_xlsxwriter::{
33 Color as XlsxColor, Format, FormatAlign, FormatBorder, Image, Url, Workbook, Worksheet, XlsxError,
34};
35
36#[derive(Debug)]
38#[non_exhaustive]
39pub enum XlsxExportError {
40 Workbook(XlsxError),
42}
43
44impl fmt::Display for XlsxExportError {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 match self {
47 Self::Workbook(e) => write!(f, "could not write the workbook: {e}"),
48 }
49 }
50}
51
52impl std::error::Error for XlsxExportError {
53 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
54 match self {
55 Self::Workbook(e) => Some(e),
56 }
57 }
58}
59
60impl From<XlsxError> for XlsxExportError {
61 fn from(e: XlsxError) -> Self {
62 Self::Workbook(e)
63 }
64}
65
66const SNAP: f32 = 1.5;
68const RULE_SNAP: f32 = 6.0;
70const FONT: &str = "Arial";
72const PIXELS_PER_POINT: f32 = 96.0 / 72.0;
74
75pub fn to_xlsx(document: &Document) -> Result<Vec<u8>, XlsxExportError> {
77 let flow = Flow::new(document);
78 let culture = Culture::named(&document.culture);
79
80 let mut xs: Vec<f32> = Vec::new();
81 let mut ys: Vec<f32> = Vec::new();
82 for t in &flow.texts {
83 xs.extend([t.rect.x(), t.rect.max_x()]);
84 ys.extend([t.rect.y(), t.rect.max_y()]);
85 }
86 for b in &flow.boxes {
87 xs.extend([b.rect.x(), b.rect.max_x()]);
88 ys.extend([b.rect.y(), b.rect.max_y()]);
89 }
90 for drawing in &flow.drawings {
91 xs.extend([drawing.rect.x(), drawing.rect.max_x()]);
92 ys.extend([drawing.rect.y(), drawing.rect.max_y()]);
93 }
94 for picture in &flow.pictures {
95 xs.extend([picture.rect.x(), picture.rect.max_x()]);
96 ys.extend([picture.rect.y(), picture.rect.max_y()]);
97 }
98 let xs = Grid::new(xs, SNAP);
99 let mut ys = Grid::new(ys, SNAP);
100 for rule in &flow.rules {
101 ys.add_if_far(rule.y, RULE_SNAP);
102 }
103
104 let mut styles: HashMap<(u32, u16), CellStyle> = HashMap::new();
105 for b in &flow.boxes {
106 let (r0, r1) = ys.span(b.rect.y(), b.rect.max_y());
107 let (c0, c1) = xs.span(b.rect.x(), b.rect.max_x());
108 for r in r0..r1 {
109 for c in c0..c1 {
110 let style = styles.entry((r, column(c))).or_default();
111 if let Some(fill) = b.fill {
112 style.fill = Some(fill);
113 }
114 if let Some(line) = b.stroke {
115 if r == r0 {
116 style.top = Some(line);
117 }
118 if r + 1 == r1 {
119 style.bottom = Some(line);
120 }
121 if c == c0 {
122 style.left = Some(line);
123 }
124 if c + 1 == c1 {
125 style.right = Some(line);
126 }
127 }
128 }
129 }
130 }
131 for rule in &flow.rules {
132 let row = ys.nearest(rule.y);
133 let (c0, c1) = xs.span(rule.x0, rule.x1);
134 for c in c0..c1 {
135 if row < ys.rows() {
137 styles.entry((row, column(c))).or_default().top = Some(rule.color);
138 } else if row > 0 {
139 styles.entry((row - 1, column(c))).or_default().bottom = Some(rule.color);
140 }
141 }
142 }
143
144 let mut book = Workbook::new();
145 let sheet = book.add_worksheet();
146 sheet.set_name(sheet_name(&document.title))?;
147 sheet.set_screen_gridlines(false);
148 for (c, width) in xs.sizes().enumerate() {
149 sheet
150 .set_column_width_pixels(column(c as u32), (width * PIXELS_PER_POINT).round().max(1.0) as u32)?;
151 }
152 for (r, height) in ys.sizes().enumerate() {
153 sheet.set_row_height(r as u32, f64::from(height).max(1.0))?;
154 }
155
156 let mut formats = Formats::default();
157 let mut taken: HashSet<(u32, u16)> = HashSet::new();
158 let mut cells: BTreeMap<(u32, u16), Placed<'_>> = BTreeMap::new();
159 for t in &flow.texts {
160 let (r0, r1) = ys.span(t.rect.y(), t.rect.max_y());
161 let (c0, c1) = xs.span(t.rect.x(), t.rect.max_x());
162 let key = (r0, column(c0));
163 if let Some(placed) = cells.get_mut(&key) {
165 placed.text.push('\n');
166 placed.text.push_str(&t.text);
167 placed.block = None;
168 continue;
169 }
170 let free = (r0..r1).all(|r| (c0..c1).all(|c| !taken.contains(&(r, column(c)))));
171 let (r1, c1) = if free { (r1, c1) } else { (r0 + 1, c0 + 1) };
172 for r in r0..r1 {
173 for c in c0..c1 {
174 taken.insert((r, column(c)));
175 }
176 }
177 let center = t.rect.center();
178 let url = flow.links.iter().find(|l| l.rect.contains(center)).map(|l| l.url.as_str());
179 cells.insert(key, Placed { text: t.text.clone(), block: Some(t.block), r1, c1, url });
180 }
181
182 for (&(r, c), style) in &styles {
184 if !taken.contains(&(r, c)) {
185 let format = formats.get(&Look { style: *style, ..Look::default() });
186 sheet.write_blank(r, c, format)?;
187 }
188 }
189 for (&(r0, c0), placed) in &cells {
190 write_cell(sheet, &mut formats, &styles, &culture, (r0, c0), placed)?;
191 }
192 for drawing in &flow.drawings {
193 let Some(pixels) = rasterize(&drawing.shapes, drawing.page_rect, DRAWING_SCALE) else { continue };
194 let png = drawing_png(&pixels)?;
195 let width = (drawing.rect.width() * PIXELS_PER_POINT).max(1.0).round() as u32;
196 let height = (drawing.rect.height() * PIXELS_PER_POINT).max(1.0).round() as u32;
197 let image = Image::new_from_buffer(&png)?.set_scale_to_size(width, height, false);
198 sheet.insert_image(ys.nearest(drawing.rect.y()), column(xs.nearest(drawing.rect.x())), &image)?;
199 }
200 for picture in &flow.pictures {
201 let width = (picture.rect.width() * PIXELS_PER_POINT).max(1.0).round() as u32;
202 let height = (picture.rect.height() * PIXELS_PER_POINT).max(1.0).round() as u32;
203 let image = Image::new_from_buffer(picture.image.encoded())?.set_scale_to_size(width, height, false);
204 sheet.insert_image(ys.nearest(picture.rect.y()), column(xs.nearest(picture.rect.x())), &image)?;
205 }
206 Ok(book.save_to_buffer()?)
207}
208
209pub fn to_csv(document: &Document) -> String {
214 let flow = Flow::new(document);
215 let separator = if Culture::named(&document.culture).decimal == ',' { ';' } else { ',' };
216 let xs = Grid::new(flow.texts.iter().map(|t| t.rect.x()).collect(), SNAP);
217 let ys = Grid::new(flow.texts.iter().map(|t| t.rect.y()).collect(), SNAP);
218 let mut rows: BTreeMap<u32, BTreeMap<u32, String>> = BTreeMap::new();
219 for t in &flow.texts {
220 let field =
221 rows.entry(ys.nearest(t.rect.y())).or_default().entry(xs.nearest(t.rect.x())).or_default();
222 if !field.is_empty() {
223 field.push(' ');
224 }
225 field.push_str(&t.text);
226 }
227 let used_last = rows.values().filter_map(|r| r.keys().next_back().copied()).max().unwrap_or(0);
228 let mut out = String::new();
229 for row in rows.values() {
230 for c in 0..=used_last {
231 if c > 0 {
232 out.push(separator);
233 }
234 if let Some(text) = row.get(&c) {
235 ravenrs_core::csv::push_field(&mut out, text, separator);
236 }
237 }
238 out.push_str("\r\n");
239 }
240 out
241}
242
243struct Flow<'d> {
245 texts: Vec<FlowText<'d>>,
246 boxes: Vec<FlowBox>,
247 rules: Vec<Rule>,
248 pictures: Vec<FlowPicture>,
249 drawings: Vec<FlowDrawing>,
250 links: Vec<FlowLink>,
251}
252
253struct FlowDrawing {
256 rect: Rect,
257 page_rect: Rect,
258 shapes: Vec<Shape>,
259}
260
261struct FlowPicture {
262 rect: Rect,
263 image: DocumentImage,
264}
265
266struct FlowLink {
267 rect: Rect,
268 url: String,
269}
270
271struct FlowText<'d> {
272 rect: Rect,
273 text: String,
274 block: &'d TextBlock,
275}
276
277struct FlowBox {
278 rect: Rect,
279 fill: Option<Rgb>,
280 stroke: Option<Rgb>,
281}
282
283struct Rule {
285 y: f32,
286 x0: f32,
287 x1: f32,
288 color: Rgb,
289}
290
291impl<'d> Flow<'d> {
292 fn new(document: &'d Document) -> Self {
293 let mut flow = Self {
294 texts: Vec::new(),
295 boxes: Vec::new(),
296 rules: Vec::new(),
297 pictures: Vec::new(),
298 drawings: Vec::new(),
299 links: Vec::new(),
300 };
301 let mut bottom = 0.0_f32;
303 for (n, page) in document.pages.iter().enumerate() {
304 let kept: Vec<&Item> = page
305 .items
306 .iter()
307 .enumerate()
308 .filter(|(i, _)| !page.page_footer.contains(i) && (n == 0 || !page.page_header.contains(i)))
309 .map(|(_, item)| item)
310 .collect();
311 let top = kept.iter().filter_map(|i| extent(i)).map(|(t, _)| t).reduce(f32::min).unwrap_or(0.0);
312 let end = kept.iter().filter_map(|i| extent(i)).map(|(_, b)| b).reduce(f32::max).unwrap_or(top);
313 let dy = bottom - top;
314 for item in kept {
315 flow.add(item, dy);
316 }
317 bottom += end - top;
318 }
319 flow
320 }
321
322 fn add(&mut self, item: &'d Item, dy: f32) {
323 match item {
324 Item::Text(block) => {
325 let text = block.lines.iter().map(|l| l.text.as_str()).collect::<Vec<_>>().join("\n");
326 if !text.is_empty() {
327 self.texts.push(FlowText { rect: shifted(block.clip, dy), text, block });
328 }
329 }
330 Item::Rect { rect, fill, stroke, .. } | Item::Ellipse { rect, fill, stroke } => {
331 let fill = fill.filter(|c| c.a > 0.0).map(rgb);
332 let stroke = stroke.filter(|(w, c)| *w > 0.0 && c.a > 0.0).map(|(_, c)| rgb(c));
333 if fill.is_some() || stroke.is_some() {
334 self.boxes.push(FlowBox { rect: shifted(*rect, dy), fill, stroke });
335 }
336 }
337 Item::Line { from, to, color, .. } if (from.y - to.y).abs() < 0.5 => {
338 self.rules.push(Rule {
339 y: from.y + dy,
340 x0: from.x.min(to.x),
341 x1: from.x.max(to.x),
342 color: rgb(*color),
343 });
344 }
345 Item::Image { rect, image, .. } => {
346 self.pictures.push(FlowPicture { rect: shifted(*rect, dy), image: image.clone() });
347 }
348 Item::Vector { rect, shapes } => self.drawings.push(FlowDrawing {
349 rect: shifted(*rect, dy),
350 page_rect: *rect,
351 shapes: shapes.clone(),
352 }),
353 Item::Link { rect, url } => {
354 self.links.push(FlowLink { rect: shifted(*rect, dy), url: url.clone() })
355 }
356 _ => {}
357 }
358 }
359}
360
361fn extent(item: &Item) -> Option<(f32, f32)> {
363 match item {
364 Item::Text(t) => Some((t.clip.y(), t.clip.max_y())),
365 Item::Rect { rect, .. } | Item::Ellipse { rect, .. } => Some((rect.y(), rect.max_y())),
366 Item::Line { from, to, .. } => Some((from.y.min(to.y), from.y.max(to.y))),
367 Item::Image { clip, .. } => Some((clip.y(), clip.max_y())),
368 Item::Vector { rect, .. } => Some((rect.y(), rect.max_y())),
369 _ => None,
370 }
371}
372
373fn shifted(rect: Rect, dy: f32) -> Rect {
374 Rect::new(rect.x(), rect.y() + dy, rect.width(), rect.height())
375}
376
377const DRAWING_SCALE: f32 = 6.0;
379
380fn drawing_png(pixels: &ImageData) -> Result<Vec<u8>, XlsxExportError> {
381 let image = image::RgbaImage::from_raw(pixels.width(), pixels.height(), pixels.pixels().to_vec())
382 .ok_or_else(|| XlsxExportError::Workbook(XlsxError::ParameterError("bad chart size".to_owned())))?;
383 let mut bytes = std::io::Cursor::new(Vec::new());
384 image
385 .write_to(&mut bytes, image::ImageFormat::Png)
386 .map_err(|e| XlsxExportError::Workbook(XlsxError::ParameterError(e.to_string())))?;
387 Ok(bytes.into_inner())
388}
389
390struct Grid {
392 lines: Vec<f32>,
393}
394
395impl Grid {
396 fn new(mut edges: Vec<f32>, snap: f32) -> Self {
397 edges.sort_by(f32::total_cmp);
398 let mut lines: Vec<f32> = Vec::new();
399 for edge in edges {
400 if lines.last().is_none_or(|last| edge - last >= snap) {
401 lines.push(edge);
402 }
403 }
404 Self { lines }
405 }
406
407 fn add_if_far(&mut self, edge: f32, snap: f32) {
408 let at = self.lines.partition_point(|l| *l < edge);
409 let near = |i: usize| self.lines.get(i).is_some_and(|l| (l - edge).abs() < snap);
410 if !(near(at) || (at > 0 && near(at - 1))) {
411 self.lines.insert(at, edge);
412 }
413 }
414
415 fn nearest(&self, edge: f32) -> u32 {
417 let at = self.lines.partition_point(|l| *l < edge);
418 let index = if at == 0 {
419 0
420 } else if at == self.lines.len() || edge - self.lines[at - 1] <= self.lines[at] - edge {
421 at - 1
422 } else {
423 at
424 };
425 index as u32
426 }
427
428 fn span(&self, from: f32, to: f32) -> (u32, u32) {
430 let start = self.nearest(from);
431 (start, self.nearest(to).max(start + 1))
432 }
433
434 fn rows(&self) -> u32 {
436 self.lines.len().saturating_sub(1) as u32
437 }
438
439 fn sizes(&self) -> impl Iterator<Item = f32> + '_ {
441 self.lines.windows(2).map(|w| w[1] - w[0])
442 }
443}
444
445fn column(c: u32) -> u16 {
446 u16::try_from(c).unwrap_or(u16::MAX)
447}
448
449type Rgb = u32;
451
452fn rgb(c: Color) -> Rgb {
453 let channel = |v: f32| ((v * c.a + 1.0 - c.a).clamp(0.0, 1.0) * 255.0).round() as u32;
454 channel(c.r) << 16 | channel(c.g) << 8 | channel(c.b)
455}
456
457#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
459struct CellStyle {
460 fill: Option<Rgb>,
461 top: Option<Rgb>,
462 bottom: Option<Rgb>,
463 left: Option<Rgb>,
464 right: Option<Rgb>,
465}
466
467impl CellStyle {
468 fn merge(&mut self, other: CellStyle) {
470 self.fill = self.fill.or(other.fill);
471 self.top = self.top.or(other.top);
472 self.bottom = self.bottom.or(other.bottom);
473 self.left = self.left.or(other.left);
474 self.right = self.right.or(other.right);
475 }
476}
477
478#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
480struct Look {
481 style: CellStyle,
482 size: u32,
484 bold: bool,
485 color: Rgb,
486 align: u8,
487 wrap: bool,
488 number: Option<String>,
489}
490
491#[derive(Default)]
493struct Formats(HashMap<Look, Format>);
494
495impl Formats {
496 fn get(&mut self, look: &Look) -> &Format {
497 self.0.entry(look.clone()).or_insert_with(|| build_format(look))
498 }
499}
500
501fn build_format(look: &Look) -> Format {
502 let mut f = Format::new();
503 if look.size > 0 {
504 f = f
505 .set_font_name(FONT)
506 .set_font_size(f64::from(look.size) / 100.0)
507 .set_font_color(XlsxColor::RGB(look.color));
508 f = f.set_align(FormatAlign::VerticalCenter).set_align(match look.align {
509 1 => FormatAlign::Center,
510 2 => FormatAlign::Right,
511 _ => FormatAlign::Left,
512 });
513 }
514 if look.bold {
515 f = f.set_bold();
516 }
517 if look.wrap {
518 f = f.set_text_wrap();
519 }
520 if let Some(number) = &look.number {
521 f = f.set_num_format(number);
522 }
523 let s = look.style;
524 if let Some(fill) = s.fill {
525 f = f.set_background_color(XlsxColor::RGB(fill));
526 }
527 if let Some(c) = s.top {
528 f = f.set_border_top(FormatBorder::Thin).set_border_top_color(XlsxColor::RGB(c));
529 }
530 if let Some(c) = s.bottom {
531 f = f.set_border_bottom(FormatBorder::Thin).set_border_bottom_color(XlsxColor::RGB(c));
532 }
533 if let Some(c) = s.left {
534 f = f.set_border_left(FormatBorder::Thin).set_border_left_color(XlsxColor::RGB(c));
535 }
536 if let Some(c) = s.right {
537 f = f.set_border_right(FormatBorder::Thin).set_border_right_color(XlsxColor::RGB(c));
538 }
539 f
540}
541
542struct Placed<'d> {
544 text: String,
545 block: Option<&'d TextBlock>,
547 r1: u32,
548 c1: u32,
549 url: Option<&'d str>,
551}
552
553fn write_cell(
554 sheet: &mut Worksheet,
555 formats: &mut Formats,
556 styles: &HashMap<(u32, u16), CellStyle>,
557 culture: &Culture,
558 (r0, c0): (u32, u16),
559 placed: &Placed<'_>,
560) -> Result<(), XlsxError> {
561 let c1 = column(placed.c1);
562 let mut style = CellStyle::default();
563 for r in r0..placed.r1 {
564 for c in c0..c1 {
565 if let Some(part) = styles.get(&(r, c)) {
566 style.merge(*part);
567 }
568 }
569 }
570 let cell =
571 placed.block.and_then(|b| b.value.as_ref().map(|v| format::typed(v, b.format.as_deref(), culture)));
572 let (number, excel) = cell.flatten().map_or((None, None), |(n, f)| (Some(n), f));
573 let block = placed.block;
574 let look = Look {
575 style,
576 size: block.map_or(1100, |b| (b.size * 100.0).round() as u32),
577 bold: block.is_some_and(|b| b.bold),
578 color: block.map_or(0, |b| rgb(b.color)),
579 align: match block.map(|b| b.align) {
580 Some(TextAlign::Center) => 1,
581 Some(TextAlign::Right) => 2,
582 _ => 0,
583 },
584 wrap: placed.text.contains('\n'),
585 number: excel,
586 };
587 let format = formats.get(&look).clone();
588 if placed.r1 > r0 + 1 || c1 > c0 + 1 {
589 sheet.merge_range(r0, c0, placed.r1 - 1, c1 - 1, &placed.text, &format)?;
590 }
591 if let Some(url) = placed.url {
592 let link = Url::new(url).set_text(&placed.text);
594 if sheet.write_url_with_format(r0, c0, link, &format).is_ok() {
595 return Ok(());
596 }
597 }
598 match number {
599 Some(n) => sheet.write_number_with_format(r0, c0, n, &format)?,
600 None => sheet.write_string_with_format(r0, c0, &placed.text, &format)?,
601 };
602 Ok(())
603}
604
605fn sheet_name(title: &str) -> String {
608 let clean: String = title
609 .chars()
610 .map(|c| if matches!(c, '[' | ']' | ':' | '*' | '?' | '/' | '\\') { ' ' } else { c })
611 .take(31)
612 .collect();
613 let clean = clean.trim().trim_matches('\'').trim();
614 if clean.is_empty() { "Report".to_owned() } else { clean.to_owned() }
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620
621 #[test]
622 fn grid_lines_snap_and_cells_never_collapse() {
623 let grid = Grid::new(vec![10.0, 10.5, 40.0, 41.0, 100.0], 1.5);
624 assert_eq!(grid.lines, [10.0, 40.0, 100.0]);
625 assert_eq!(grid.span(10.4, 40.7), (0, 1));
626 assert_eq!(grid.span(40.0, 40.2), (1, 2));
627 assert_eq!(grid.span(10.0, 100.0), (0, 2));
628 }
629
630 #[test]
631 fn rules_near_a_line_add_no_row() {
632 let mut grid = Grid::new(vec![0.0, 20.0], 1.5);
633 grid.add_if_far(21.0, 6.0);
634 assert_eq!(grid.lines, [0.0, 20.0]);
635 grid.add_if_far(40.0, 6.0);
636 assert_eq!(grid.lines, [0.0, 20.0, 40.0]);
637 }
638
639 #[test]
640 fn sheet_names_are_what_excel_accepts() {
641 assert_eq!(sheet_name("Vendas: 2026/11"), "Vendas 2026 11");
642 assert_eq!(sheet_name("'Fatura'"), "Fatura");
643 assert_eq!(sheet_name("???"), "Report");
644 assert_eq!(sheet_name(&"x".repeat(40)).chars().count(), 31);
645 }
646
647 #[test]
648 fn translucent_colors_blend_over_white() {
649 assert_eq!(rgb(Color::rgba(0.0, 0.0, 0.0, 1.0)), 0x000000);
650 assert_eq!(rgb(Color::rgba(0.0, 0.0, 0.0, 0.0)), 0xffffff);
651 assert_eq!(rgb(Color::rgba(1.0, 0.0, 0.0, 0.5)), 0xff8080);
652 }
653}