From: Ben Pfaff Date: Wed, 24 Sep 2025 15:56:09 +0000 (-0700) Subject: rust: Move output drivers to drivers module. X-Git-Url: https://pintos-os.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=010d293a18e572917d2d0427c54977a57c4770e8;p=pspp rust: Move output drivers to drivers module. --- diff --git a/rust/doc/src/spv/index.md b/rust/doc/src/spv/index.md index 100a531c41..499d6dbf26 100644 --- a/rust/doc/src/spv/index.md +++ b/rust/doc/src/spv/index.md @@ -42,18 +42,19 @@ table, a heading, a block of text, etc.) or a group of them. The member whose output goes at the beginning of the document is numbered 0, the next member in the output is numbered 1, and so on. -Structure members contain XML. This XML is sometimes self-contained, +[Structure members] contain XML. This XML is sometimes self-contained, but it often references detail members in the Zip archive, which are named as follows: * `PREFIX_table.xml` and `PREFIX_tableData.bin` `PREFIX_lightTableData.bin` The structure of a table plus its data. Older SPV files pair a - `PREFIX_table.xml` file that describes the table's structure with a - binary `PREFIX_tableData.bin` file that gives its data. Newer SPV - files (the majority of those in the corpus) instead include a - single `PREFIX_lightTableData.bin` file that incorporates both into - a single binary format. + `PREFIX_table.xml` [legacy detail XML member] that describes the + table's structure with a `PREFIX_tableData.bin` [legacy detail + binary member] that gives its data. Newer SPV files (the majority + of those in the corpus) instead include a single + `PREFIX_lightTableData.bin` [light detail binary member] that + incorporates both into a single binary format. * `PREFIX_warning.xml` and `PREFIX_warningData.bin` `PREFIX_lightWarningData.bin` @@ -88,3 +89,8 @@ their exact names do not matter to readers as long as they are unique. SPSS tolerates corrupted Zip archives that Zip reader libraries tend to reject. These can be fixed up with `zip -FF`. + +[Structure members]: structure.md +[legacy detail XML member]: legacy-detail-xml.md +[legacy detail binary member]: legacy-detail-binary.md +[light detail binary member]: light-detail.md diff --git a/rust/pspp/src/file.rs b/rust/pspp/src/file.rs index 7c9cf4fe52..bc9bb85ec4 100644 --- a/rust/pspp/src/file.rs +++ b/rust/pspp/src/file.rs @@ -42,7 +42,7 @@ pub enum FileType { /// An SPSS PC+ data file. Pc, - /// An [SPSS Viewer file](crate::output::spv). + /// An [SPSS Viewer file](crate::output::drivers::spv). Viewer { /// Whether the file is encrypted. encrypted: bool, diff --git a/rust/pspp/src/output.rs b/rust/pspp/src/output.rs index c1e061ed9b..23f2435d36 100644 --- a/rust/pspp/src/output.rs +++ b/rust/pspp/src/output.rs @@ -31,18 +31,11 @@ use crate::{ use self::pivot::Value; -pub mod cairo; -pub mod csv; -pub mod driver; -pub mod html; -pub mod json; +pub mod drivers; pub mod page; pub mod pivot; pub mod render; -pub mod spv; pub mod table; -pub mod text; -pub mod text_line; /// A single output item. #[derive(Serialize)] diff --git a/rust/pspp/src/output/cairo.rs b/rust/pspp/src/output/cairo.rs deleted file mode 100644 index 260e5c3e2c..0000000000 --- a/rust/pspp/src/output/cairo.rs +++ /dev/null @@ -1,52 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use pango::SCALE; - -use crate::output::pivot::HorzAlign; - -mod driver; -pub mod fsm; -pub mod pager; - -pub use driver::{CairoConfig, CairoDriver}; - -/// Conversion from 1/96" units ("pixels") to Cairo/Pango units. -fn px_to_xr(x: usize) -> usize { - x * 3 * (SCALE as usize * 72 / 96) / 3 -} - -fn xr_to_pt(x: usize) -> f64 { - x as f64 / SCALE as f64 -} - -fn horz_align_to_pango(horz_align: HorzAlign) -> pango::Alignment { - match horz_align { - HorzAlign::Right | HorzAlign::Decimal { .. } => pango::Alignment::Right, - HorzAlign::Left => pango::Alignment::Left, - HorzAlign::Center => pango::Alignment::Center, - } -} - -#[cfg(test)] -mod tests { - use crate::output::cairo::{CairoConfig, CairoDriver}; - - #[test] - fn create() { - CairoDriver::new(&CairoConfig::new("test.pdf")).unwrap(); - } -} diff --git a/rust/pspp/src/output/cairo/driver.rs b/rust/pspp/src/output/cairo/driver.rs deleted file mode 100644 index e239298110..0000000000 --- a/rust/pspp/src/output/cairo/driver.rs +++ /dev/null @@ -1,162 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use std::{ - borrow::Cow, - path::{Path, PathBuf}, - sync::Arc, -}; - -use cairo::{Context, PdfSurface}; -use enum_map::{EnumMap, enum_map}; -use pango::SCALE; -use serde::{Deserialize, Serialize}; - -use crate::output::{ - Item, - cairo::{ - fsm::{CairoFsmStyle, parse_font_style}, - pager::{CairoPageStyle, CairoPager}, - }, - driver::Driver, - page::PageSetup, - pivot::{Color, Coord2, FontStyle}, -}; - -use crate::output::pivot::Axis2; - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct CairoConfig { - /// Output file name. - pub file: PathBuf, - - /// Page setup. - pub page_setup: Option, -} - -impl CairoConfig { - pub fn new(path: impl AsRef) -> Self { - Self { - file: path.as_ref().to_path_buf(), - page_setup: None, - } - } -} - -pub struct CairoDriver { - fsm_style: Arc, - page_style: Arc, - pager: Option, - surface: PdfSurface, -} - -impl CairoDriver { - pub fn new(config: &CairoConfig) -> cairo::Result { - fn scale(inches: f64) -> usize { - (inches * 72.0 * SCALE as f64).max(0.0).round() as usize - } - - let default_page_setup; - let page_setup = match &config.page_setup { - Some(page_setup) => page_setup, - None => { - default_page_setup = PageSetup::default(); - &default_page_setup - } - }; - let printable = page_setup.printable_size(); - let page_style = CairoPageStyle { - margins: EnumMap::from_fn(|axis| { - [ - scale(page_setup.margins[axis][0]), - scale(page_setup.margins[axis][1]), - ] - }), - headings: page_setup.headings.clone(), - initial_page_number: page_setup.initial_page_number, - }; - let size = Coord2::new(scale(printable[Axis2::X]), scale(printable[Axis2::Y])); - let font = FontStyle { - bold: false, - italic: false, - underline: false, - markup: false, - font: "Sans Serif".into(), - fg: [Color::BLACK, Color::BLACK], - bg: [Color::WHITE, Color::WHITE], - size: 10, - }; - let font = parse_font_style(&font); - let fsm_style = CairoFsmStyle { - size, - min_break: enum_map! { - Axis2::X => size[Axis2::X] / 2, - Axis2::Y => size[Axis2::Y] / 2, - }, - font, - fg: Color::BLACK, - use_system_colors: false, - object_spacing: scale(page_setup.object_spacing), - font_resolution: 72.0, - }; - let surface = PdfSurface::new( - page_setup.paper[Axis2::X] * 72.0, - page_setup.paper[Axis2::Y] * 72.0, - &config.file, - )?; - Ok(Self { - fsm_style: Arc::new(fsm_style), - page_style: Arc::new(page_style), - pager: None, - surface, - }) - } -} - -impl Driver for CairoDriver { - fn name(&self) -> Cow<'static, str> { - Cow::from("cairo") - } - - fn write(&mut self, item: &Arc) { - let pager = self.pager.get_or_insert_with(|| { - let mut pager = CairoPager::new(self.page_style.clone(), self.fsm_style.clone()); - pager.add_page(Context::new(&self.surface).unwrap()); - pager - }); - pager.add_item(item.clone()); - dbg!(); - while pager.needs_new_page() { - dbg!(); - pager.finish_page(); - let context = Context::new(&self.surface).unwrap(); - context.show_page().unwrap(); - pager.add_page(context); - } - dbg!(); - } -} - -impl Drop for CairoDriver { - fn drop(&mut self) { - dbg!(); - if self.pager.is_some() { - dbg!(); - let context = Context::new(&self.surface).unwrap(); - context.show_page().unwrap(); - } - } -} diff --git a/rust/pspp/src/output/cairo/fsm.rs b/rust/pspp/src/output/cairo/fsm.rs deleted file mode 100644 index 13597f30a0..0000000000 --- a/rust/pspp/src/output/cairo/fsm.rs +++ /dev/null @@ -1,762 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use std::{cmp::min, f64::consts::PI, fmt::Write, ops::DerefMut, sync::Arc}; - -use cairo::Context; -use enum_map::{EnumMap, enum_map}; -use itertools::Itertools; -use pango::{ - AttrFloat, AttrFontDesc, AttrInt, AttrList, Attribute, FontDescription, FontMask, Layout, - SCALE, SCALE_SMALL, Underline, Weight, parse_markup, -}; -use pangocairo::functions::show_layout; -use smallvec::{SmallVec, smallvec}; - -use crate::output::cairo::{horz_align_to_pango, px_to_xr, xr_to_pt}; -use crate::output::pivot::{Axis2, BorderStyle, Coord2, FontStyle, HorzAlign, Rect2, Stroke}; -use crate::output::render::{Device, Extreme, Pager, Params}; -use crate::output::table::DrawCell; -use crate::output::{Details, Item}; -use crate::output::{pivot::Color, table::Content}; - -/// Width of an ordinary line. -const LINE_WIDTH: usize = LINE_SPACE / 2; - -/// Space between double lines. -const LINE_SPACE: usize = SCALE as usize; - -/// Conversion from 1/96" units ("pixels") to Cairo/Pango units. -fn pxf_to_xr(x: f64) -> usize { - (x * (SCALE as f64 * 72.0 / 96.0)).round() as usize -} - -#[derive(Clone, Debug)] -pub struct CairoFsmStyle { - /// Page size. - pub size: Coord2, - - /// Minimum cell size to allow breaking. - pub min_break: EnumMap, - - /// The basic font. - pub font: FontDescription, - - /// Foreground color. - pub fg: Color, - - /// Use system colors? - pub use_system_colors: bool, - - /// Vertical space between different output items. - pub object_spacing: usize, - - /// Resolution, in units per inch, used for measuring font "points": - /// - /// - 72.0 if 1 pt is one device unit, e.g. for rendering to a surface - /// created by [cairo::PsSurface::new] with its default transformation - /// matrix of 72 units/inch.p - /// - /// - 96.0 is traditional for a screen-based surface. - pub font_resolution: f64, -} - -impl CairoFsmStyle { - fn new_layout(&self, context: &Context) -> Layout { - let pangocairo_context = pangocairo::functions::create_context(context); - pangocairo::functions::context_set_resolution(&pangocairo_context, self.font_resolution); - Layout::new(&pangocairo_context) - } -} - -pub struct CairoFsm { - style: Arc, - params: Params, - item: Arc, - layer_iterator: Option>>>, - pager: Option, -} - -impl CairoFsm { - pub fn new( - style: Arc, - printing: bool, - context: &Context, - item: Arc, - ) -> Self { - let params = Params { - size: style.size, - font_size: { - let layout = style.new_layout(context); - layout.set_font_description(Some(&style.font)); - layout.set_text("0"); - let char_size = layout.size(); - enum_map! { - Axis2::X => char_size.0.max(0) as usize, - Axis2::Y => char_size.1.max(0) as usize - } - }, - line_widths: enum_map! { - Stroke::None => 0, - Stroke::Solid | Stroke::Dashed => LINE_WIDTH, - Stroke::Thick => LINE_WIDTH * 2, - Stroke::Thin => LINE_WIDTH / 2, - Stroke::Double => LINE_WIDTH * 2 + LINE_SPACE, - }, - px_size: Some(px_to_xr(1)), - min_break: style.min_break, - supports_margins: true, - rtl: false, - printing, - can_adjust_break: false, // XXX - can_scale: true, - }; - let device = CairoDevice { - style: &style, - params: ¶ms, - context, - }; - let (layer_iterator, pager) = match &item.details { - Details::Table(pivot_table) => { - let mut layer_iterator = pivot_table.layers(printing); - let layer_indexes = layer_iterator.next(); - ( - Some(layer_iterator), - Some(Pager::new( - &device, - pivot_table, - layer_indexes.as_ref().map(|indexes| indexes.as_slice()), - )), - ) - } - _ => (None, None), - }; - Self { - style, - params, - item, - layer_iterator, - pager, - } - } - - pub fn draw_slice(&mut self, context: &Context, space: usize) -> usize { - debug_assert!(self.params.printing); - - context.save().unwrap(); - let used = match &self.item.details { - Details::Table(_) => self.draw_table(context, space), - _ => todo!(), - }; - context.restore().unwrap(); - - used - } - - fn draw_table(&mut self, context: &Context, space: usize) -> usize { - let Details::Table(pivot_table) = &self.item.details else { - unreachable!() - }; - let Some(pager) = &mut self.pager else { - return 0; - }; - let mut device = CairoDevice { - style: &self.style, - params: &self.params, - context, - }; - let mut used = pager.draw_next(&mut device, space); - if !pager.has_next(&device) { - match self.layer_iterator.as_mut().unwrap().next() { - Some(layer_indexes) => { - self.pager = Some(Pager::new( - &device, - pivot_table, - Some(layer_indexes.as_slice()), - )); - if pivot_table.look.paginate_layers { - used = space; - } else { - used += self.style.object_spacing; - } - } - _ => { - self.pager = None; - } - } - } - used.min(space) - } - - pub fn is_done(&self) -> bool { - match &self.item.details { - Details::Table(_) => self.pager.is_none(), - _ => todo!(), - } - } -} - -fn xr_clip(context: &Context, clip: &Rect2) { - if clip[Axis2::X].end != usize::MAX || clip[Axis2::Y].end != usize::MAX { - let x0 = xr_to_pt(clip[Axis2::X].start); - let y0 = xr_to_pt(clip[Axis2::Y].start); - let x1 = xr_to_pt(clip[Axis2::X].end); - let y1 = xr_to_pt(clip[Axis2::Y].end); - context.rectangle(x0, y0, x1 - x0, y1 - y0); - context.clip(); - } -} - -fn xr_set_color(context: &Context, color: &Color) { - fn as_frac(x: u8) -> f64 { - x as f64 / 255.0 - } - - context.set_source_rgba( - as_frac(color.r), - as_frac(color.g), - as_frac(color.b), - as_frac(color.alpha), - ); -} - -fn xr_fill_rectangle(context: &Context, rectangle: Rect2) { - context.new_path(); - context.set_line_width(xr_to_pt(LINE_WIDTH)); - - let x0 = xr_to_pt(rectangle[Axis2::X].start); - let y0 = xr_to_pt(rectangle[Axis2::Y].start); - let width = xr_to_pt(rectangle[Axis2::X].len()); - let height = xr_to_pt(rectangle[Axis2::Y].len()); - context.rectangle(x0, y0, width, height); - context.fill().unwrap(); -} - -fn margin(cell: &DrawCell, axis: Axis2) -> usize { - px_to_xr( - cell.style.cell_style.margins[axis] - .iter() - .sum::() - .max(0) as usize, - ) -} - -pub fn parse_font_style(font_style: &FontStyle) -> FontDescription { - let font = font_style.font.as_str(); - let font = if font.eq_ignore_ascii_case("Monospaced") { - "Monospace" - } else { - font - }; - let mut font_desc = FontDescription::from_string(font); - if !font_desc.set_fields().contains(FontMask::SIZE) { - let default_size = if font_style.size != 0 { - font_style.size * 1000 - } else { - 10_000 - }; - font_desc.set_size(((default_size as f64 / 1000.0) * (SCALE as f64)) as i32); - } - font_desc.set_weight(if font_style.bold { - Weight::Bold - } else { - Weight::Normal - }); - font_desc.set_style(if font_style.italic { - pango::Style::Italic - } else { - pango::Style::Normal - }); - font_desc -} - -/// Deal with an oddity of the Unicode line-breaking algorithm (or perhaps in -/// Pango's implementation of it): it will break after a period or a comma that -/// precedes a digit, e.g. in `.000` it will break after the period. This code -/// looks for such a situation and inserts a U+2060 WORD JOINER to prevent the -/// break. -/// -/// This isn't necessary when the decimal point is between two digits -/// (e.g. `0.000` won't be broken) or when the display width is not limited so -/// that word wrapping won't happen. -/// -/// It isn't necessary to look for more than one period or comma, as would -/// happen with grouping like `1,234,567.89` or `1.234.567,89` because if groups -/// are present then there will always be a digit on both sides of every period -/// and comma. -fn avoid_decimal_split(mut s: String) -> String { - if let Some(position) = s.find(['.', ',']) { - let followed_by_digit = s[position + 1..] - .chars() - .next() - .is_some_and(|c| c.is_ascii_digit()); - let not_preceded_by_digit = s[..position] - .chars() - .next_back() - .is_none_or(|c| !c.is_ascii_digit()); - if followed_by_digit && not_preceded_by_digit { - s.insert(position + 1, '\u{2060}'); - } - } - s -} - -struct CairoDevice<'a> { - style: &'a CairoFsmStyle, - params: &'a Params, - context: &'a Context, -} - -impl CairoDevice<'_> { - fn layout_cell(&self, cell: &DrawCell, mut bb: Rect2, clip: &Rect2) -> Coord2 { - // XXX rotation - //let h = if cell.rotate { Axis2::Y } else { Axis2::X }; - - let layout = self.style.new_layout(self.context); - - let cell_font = if !cell.style.font_style.font.is_empty() { - Some(parse_font_style(&cell.style.font_style)) - } else { - None - }; - let font = cell_font.as_ref().unwrap_or(&self.style.font); - layout.set_font_description(Some(font)); - - let (body, suffixes) = cell.display().split_suffixes(); - let horz_align = cell.horz_align(&body); - let body = body.to_string(); - - match horz_align { - HorzAlign::Decimal { offset, decimal } if !cell.rotate => { - let decimal_position = if let Some(position) = body.rfind(char::from(decimal)) { - layout.set_text(&body[position..]); - layout.set_width(-1); - layout.size().0.max(0) as usize - } else { - 0 - }; - bb[Axis2::X].end -= pxf_to_xr(offset).saturating_sub(decimal_position); - } - _ => (), - } - - let mut attrs = None; - let mut body = if cell.style.font_style.markup { - match parse_markup(&body, 0 as char) { - Ok((markup_attrs, string, _accel)) => { - attrs = Some(markup_attrs); - string.into() - } - Err(_) => body, - } - } else { - avoid_decimal_split(body) - }; - - if cell.style.font_style.underline { - attrs - .get_or_insert_default() - .insert(AttrInt::new_underline(Underline::Single)); - } - - if !suffixes.is_empty() { - let subscript_ofs = body.len(); - #[allow(unstable_name_collisions)] - body.extend(suffixes.subscripts().intersperse(",")); - let has_subscripts = subscript_ofs != body.len(); - - let footnote_ofs = body.len(); - for (index, footnote) in suffixes.footnotes().enumerate() { - if index > 0 { - body.push(','); - } - write!(&mut body, "{footnote}").unwrap(); - } - let has_footnotes = footnote_ofs != body.len(); - - // Allow footnote markers to occupy the right margin. That way, - // numbers in the column are still aligned. - if has_footnotes && horz_align == HorzAlign::Right { - // Measure the width of the footnote marker, so we know how much we - // need to make room for. - layout.set_text(&body[footnote_ofs..]); - - let footnote_attrs = AttrList::new(); - footnote_attrs.insert(AttrFloat::new_scale(SCALE_SMALL)); - footnote_attrs.insert(AttrInt::new_rise(3000)); - layout.set_attributes(Some(&footnote_attrs)); - let footnote_width = layout.size().0.max(0) as usize; - - // Bound the adjustment by the width of the right margin. - let right_margin = - px_to_xr(cell.style.cell_style.margins[Axis2::X][1].max(0) as usize); - let footnote_adjustment = min(footnote_width, right_margin); - - // Adjust the bounding box. - if cell.rotate { - bb[Axis2::X].end = bb[Axis2::X].end.saturating_sub(footnote_adjustment); - } else { - bb[Axis2::X].end = bb[Axis2::X].end.saturating_add(footnote_adjustment); - } - - // Clean up. - layout.set_attributes(None); - } - - fn with_start>(index: usize, mut attr: T) -> T { - attr.deref_mut().set_start_index(index.try_into().unwrap()); - attr - } - fn with_end>(index: usize, mut attr: T) -> T { - attr.deref_mut().set_end_index(index.try_into().unwrap()); - attr - } - - // Set attributes. - let attrs = attrs.get_or_insert_default(); - attrs.insert(with_start(subscript_ofs, AttrFontDesc::new(font))); - attrs.insert(with_start(subscript_ofs, AttrFloat::new_scale(SCALE_SMALL))); - if has_subscripts { - attrs.insert(with_start( - subscript_ofs, - with_end(footnote_ofs, AttrInt::new_rise(-3000)), - )); - } - if has_footnotes { - let rise = 3000; // XXX check look for superscript vs subscript - attrs.insert(with_start(footnote_ofs, AttrInt::new_rise(rise))); - } - } - - layout.set_attributes(attrs.as_ref()); - layout.set_text(&body); - layout.set_alignment(horz_align_to_pango(horz_align)); - if bb[Axis2::X].end == usize::MAX { - layout.set_width(-1); - } else { - layout.set_width(bb[Axis2::X].len() as i32); - } - - let size = layout.size(); - - if !clip.is_empty() { - self.context.save().unwrap(); - if !cell.rotate { - xr_clip(self.context, clip); - } - if cell.rotate { - let extra = bb[Axis2::X].len().saturating_sub(size.1.max(0) as usize); - let halign_offset = extra / 2; - self.context.translate( - xr_to_pt(bb[Axis2::X].start + halign_offset), - xr_to_pt(bb[Axis2::Y].end), - ); - self.context.rotate(-PI / 2.0); - } else { - self.context - .translate(xr_to_pt(bb[Axis2::X].start), xr_to_pt(bb[Axis2::Y].start)); - } - show_layout(self.context, &layout); - self.context.restore().unwrap(); - } - - layout.set_attributes(None); - - Coord2::new(size.0.max(0) as usize, size.1.max(0) as usize) - } - - fn do_draw_line( - &self, - x0: usize, - y0: usize, - x1: usize, - y1: usize, - stroke: Stroke, - color: Color, - ) { - self.context.new_path(); - self.context.set_line_width(xr_to_pt(match stroke { - Stroke::Thick => LINE_WIDTH * 2, - Stroke::Thin => LINE_WIDTH / 2, - _ => LINE_WIDTH, - })); - self.context.move_to(xr_to_pt(x0), xr_to_pt(y0)); - self.context.line_to(xr_to_pt(x1), xr_to_pt(y1)); - if !self.style.use_system_colors { - xr_set_color(self.context, &color); - } - if stroke == Stroke::Dashed { - self.context.set_dash(&[2.0], 0.0); - let _ = self.context.stroke(); - self.context.set_dash(&[], 0.0); - } else { - let _ = self.context.stroke(); - } - } -} - -impl Device for CairoDevice<'_> { - fn params(&self) -> &Params { - self.params - } - - fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap { - fn add_margins(cell: &DrawCell, width: usize) -> usize { - if width > 0 { - width + margin(cell, Axis2::X) - } else { - 0 - } - } - - /// An empty clipping rectangle. - fn clip() -> Rect2 { - Rect2::default() - } - - enum_map![ - Extreme::Min => { - let bb = Rect2::new(0..1, 0..usize::MAX); - add_margins(cell, self.layout_cell(cell, bb, &clip()).x()) - } - Extreme::Max => { - let bb = Rect2::new(0..usize::MAX, 0..usize::MAX); - add_margins(cell, self.layout_cell(cell, bb, &clip()).x()) - }, - ] - } - - fn measure_cell_height(&self, cell: &DrawCell, width: usize) -> usize { - let margins = &cell.style.cell_style.margins; - let bb = Rect2::new( - 0..width.saturating_sub(px_to_xr(margins[Axis2::X].len())), - 0..usize::MAX, - ); - self.layout_cell(cell, bb, &Rect2::default()).y() + margin(cell, Axis2::Y) - } - - fn adjust_break(&self, _cell: &Content, _size: Coord2) -> usize { - todo!() - } - - fn draw_line(&mut self, bb: Rect2, styles: EnumMap) { - let x0 = bb[Axis2::X].start; - let y0 = bb[Axis2::Y].start; - let x3 = bb[Axis2::X].end; - let y3 = bb[Axis2::Y].end; - - let top = styles[Axis2::X][0].stroke; - let bottom = styles[Axis2::X][1].stroke; - let left = styles[Axis2::Y][0].stroke; - let right = styles[Axis2::Y][1].stroke; - - let top_color = styles[Axis2::X][0].color; - let bottom_color = styles[Axis2::X][1].color; - let left_color = styles[Axis2::Y][0].color; - let right_color = styles[Axis2::Y][1].color; - - // The algorithm here is somewhat subtle, to allow it to handle - // all the kinds of intersections that we need. - // - // Three additional ordinates are assigned along the X axis. The first - // is `xc`, midway between `x0` and `x3`. The others are `x1` and `x2`; - // for a single vertical line these are equal to `xc`, and for a double - // vertical line they are the ordinates of the left and right half of - // the double line. - // - // `yc`, `y1`, and `y2` are assigned similarly along the Y axis. - // - // The following diagram shows the coordinate system and output for - // double top and bottom lines, single left line, and no right line: - // - // ``` - // x0 x1 xc x2 x3 - // y0 ________________________ - // | # # | - // | # # | - // | # # | - // | # # | - // | # # | - // y1 = y2 = yc |######### # | - // | # # | - // | # # | - // | # # | - // | # # | - // y3 |________#_____#_______| - // ``` - - // Offset from center of each line in a pair of double lines. - let double_line_ofs = (LINE_SPACE + LINE_WIDTH) / 2; - - // Are the lines along each axis single or double? (It doesn't make - // sense to have different kinds of line on the same axis, so we don't - // try to gracefully handle that case.) - let double_vert = top == Stroke::Double || bottom == Stroke::Double; - let double_horz = left == Stroke::Double || right == Stroke::Double; - - // When horizontal lines are doubled, the left-side line along `y1` - // normally runs from `x0` to `x2`, and the right-side line along `y1` - // from `x3` to `x1`. If the top-side line is also doubled, we shorten - // the `y1` lines, so that the left-side line runs only to `x1`, and the - // right-side line only to `x2`. Otherwise, the horizontal line at `y = - // y1` below would cut off the intersection, which looks ugly: - // - // ``` - // x0 x1 x2 x3 - // y0 ________________________ - // | # # | - // | # # | - // | # # | - // | # # | - // y1 |######### ########| - // | | - // | | - // y2 |######################| - // | | - // | | - // y3 |______________________| - // ``` - // - // It is more of a judgment call when the horizontal line is single. We - // choose to cut off the line anyhow, as shown in the first diagram - // above. - let shorten_y1_lines = top == Stroke::Double; - let shorten_y2_lines = bottom == Stroke::Double; - let shorten_yc_line = shorten_y1_lines && shorten_y2_lines; - let horz_line_ofs = if double_vert { double_line_ofs } else { 0 }; - let xc = (x0 + x3) / 2; - let x1 = xc - horz_line_ofs; - let x2 = xc + horz_line_ofs; - - let shorten_x1_lines = left == Stroke::Double; - let shorten_x2_lines = right == Stroke::Double; - let shorten_xc_line = shorten_x1_lines && shorten_x2_lines; - let vert_line_ofs = if double_horz { double_line_ofs } else { 0 }; - let yc = (y0 + y3) / 2; - let y1 = yc - vert_line_ofs; - let y2 = yc + vert_line_ofs; - - let horz_lines: SmallVec<[_; 2]> = if double_horz { - smallvec![(y1, shorten_y1_lines), (y2, shorten_y2_lines)] - } else { - smallvec![(yc, shorten_yc_line)] - }; - for (y, shorten) in horz_lines { - if left != Stroke::None - && right != Stroke::None - && !shorten - && left_color == right_color - { - self.do_draw_line(x0, y, x3, y, left, left_color); - } else { - if left != Stroke::None { - self.do_draw_line(x0, y, if shorten { x1 } else { x2 }, y, left, left_color); - } - if right != Stroke::None { - self.do_draw_line(if shorten { x2 } else { x1 }, y, x3, y, right, right_color); - } - } - } - - let vert_lines: SmallVec<[_; 2]> = if double_vert { - smallvec![(x1, shorten_x1_lines), (x2, shorten_x2_lines)] - } else { - smallvec![(xc, shorten_xc_line)] - }; - for (x, shorten) in vert_lines { - if top != Stroke::None - && bottom != Stroke::None - && !shorten - && top_color == bottom_color - { - self.do_draw_line(x, y0, x, y3, top, top_color); - } else { - if top != Stroke::None { - self.do_draw_line(x, y0, x, if shorten { y1 } else { y2 }, top, top_color); - } - if bottom != Stroke::None { - self.do_draw_line( - x, - if shorten { y2 } else { y1 }, - x, - y3, - bottom, - bottom_color, - ); - } - } - } - } - - fn draw_cell( - &mut self, - draw_cell: &DrawCell, - alternate_row: bool, - mut bb: Rect2, - valign_offset: usize, - spill: EnumMap, - clip: &Rect2, - ) { - let fg = &draw_cell.style.font_style.fg[alternate_row as usize]; - let bg = &draw_cell.style.font_style.bg[alternate_row as usize]; - - if (bg.r != 255 || bg.g != 255 || bg.b != 255) && bg.alpha != 0 { - self.context.save().unwrap(); - let bg_clip = Rect2::from_fn(|axis| { - let start = if bb[axis].start == clip[axis].start { - clip[axis].start.saturating_sub(spill[axis][0]) - } else { - clip[axis].start - }; - let end = if bb[axis].end == clip[axis].end { - clip[axis].end + spill[axis][1] - } else { - clip[axis].end - }; - start..end - }); - xr_clip(self.context, &bg_clip); - xr_set_color(self.context, bg); - let x0 = bb[Axis2::X].start.saturating_sub(spill[Axis2::X][0]); - let y0 = bb[Axis2::Y].start.saturating_sub(spill[Axis2::X][1]); - let x1 = bb[Axis2::X].end + spill[Axis2::X][1]; - let y1 = bb[Axis2::Y].end + spill[Axis2::Y][1]; - xr_fill_rectangle(self.context, Rect2::new(x0..x1, y0..y1)); - self.context.restore().unwrap(); - } - - if !self.style.use_system_colors { - xr_set_color(self.context, fg); - } - - self.context.save().unwrap(); - bb[Axis2::Y].start += valign_offset; - for axis in [Axis2::X, Axis2::Y] { - bb[axis].start += px_to_xr(draw_cell.style.cell_style.margins[axis][0].max(0) as usize); - bb[axis].end = bb[axis] - .end - .saturating_sub(draw_cell.style.cell_style.margins[axis][0].max(0) as usize); - } - if bb[Axis2::X].start < bb[Axis2::X].end && bb[Axis2::Y].start < bb[Axis2::Y].end { - self.layout_cell(draw_cell, bb, clip); - } - self.context.restore().unwrap(); - } - - fn scale(&mut self, factor: f64) { - self.context.scale(factor, factor); - } -} diff --git a/rust/pspp/src/output/cairo/pager.rs b/rust/pspp/src/output/cairo/pager.rs deleted file mode 100644 index 6106eb6a0e..0000000000 --- a/rust/pspp/src/output/cairo/pager.rs +++ /dev/null @@ -1,237 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use std::sync::Arc; - -use cairo::{Context, RecordingSurface}; -use enum_map::EnumMap; -use pango::{FontDescription, Layout}; - -use crate::output::{ - Item, ItemCursor, - cairo::{ - fsm::{CairoFsm, CairoFsmStyle}, - horz_align_to_pango, xr_to_pt, - }, - page::Heading, - pivot::Axis2, -}; - -#[derive(Clone, Debug)] -pub struct CairoPageStyle { - pub margins: EnumMap, - pub headings: [Heading; 2], - pub initial_page_number: i32, -} - -pub struct CairoPager { - page_style: Arc, - fsm_style: Arc, - page_index: i32, - heading_heights: [usize; 2], - iter: Option, - context: Option, - fsm: Option, - y: usize, -} - -impl CairoPager { - pub fn new(mut page_style: Arc, mut fsm_style: Arc) -> Self { - let heading_heights = measure_headings(&page_style, &fsm_style); - let total = heading_heights.iter().sum::(); - if (0..fsm_style.size[Axis2::Y]).contains(&total) { - let fsm_style = Arc::make_mut(&mut fsm_style); - let page_style = Arc::make_mut(&mut page_style); - #[allow(clippy::needless_range_loop)] - for i in 0..2 { - page_style.margins[Axis2::Y][i] += heading_heights[i]; - } - fsm_style.size[Axis2::Y] -= total; - } - Self { - page_style, - fsm_style, - page_index: 0, - heading_heights, - iter: None, - context: None, - fsm: None, - y: 0, - } - } - - pub fn add_page(&mut self, context: Context) { - assert!(self.context.is_none()); - context.save().unwrap(); - self.y = 0; - - context.translate( - xr_to_pt(self.page_style.margins[Axis2::X][0]), - xr_to_pt(self.page_style.margins[Axis2::Y][0]), - ); - - let page_number = self.page_index + self.page_style.initial_page_number; - self.page_index += 1; - - if self.heading_heights[0] > 0 { - render_heading( - &context, - &self.fsm_style.font, - &self.page_style.headings[0], - page_number, - self.fsm_style.size[Axis2::X], - 0, /* XXX*/ - self.fsm_style.font_resolution, - ); - } - if self.heading_heights[0] > 0 { - render_heading( - &context, - &self.fsm_style.font, - &self.page_style.headings[1], - page_number, - self.fsm_style.size[Axis2::X], - self.fsm_style.size[Axis2::Y] + self.fsm_style.object_spacing, - self.fsm_style.font_resolution, - ); - } - - self.context = Some(context); - self.run(); - } - - pub fn finish_page(&mut self) { - if let Some(context) = self.context.take() { - context.restore().unwrap(); - } - } - - pub fn needs_new_page(&mut self) -> bool { - if self.iter.is_some() - && (self.context.is_none() || self.y >= self.fsm_style.size[Axis2::Y]) - { - self.finish_page(); - true - } else { - false - } - } - - pub fn add_item(&mut self, item: Arc) { - self.iter = Some(ItemCursor::new(item)); - self.run(); - } - - fn run(&mut self) { - let Some(context) = self.context.as_ref().cloned() else { - return; - }; - if self.iter.is_none() || self.y >= self.fsm_style.size[Axis2::Y] { - return; - } - - loop { - // Make sure we've got an object to render. - let fsm = match &mut self.fsm { - Some(fsm) => fsm, - None => { - // If there are no remaining objects to render, then we're done. - let Some(iter) = self.iter.as_mut() else { - return; - }; - let Some(item) = iter.cur().cloned() else { - self.iter = None; - return; - }; - iter.next(); - self.fsm - .insert(CairoFsm::new(self.fsm_style.clone(), true, &context, item)) - } - }; - - // Prepare to render the current object. - let chunk = fsm.draw_slice( - &context, - self.fsm_style.size[Axis2::Y].saturating_sub(self.y), - ); - self.y += chunk + self.fsm_style.object_spacing; - context.translate(0.0, xr_to_pt(chunk + self.fsm_style.object_spacing)); - - if fsm.is_done() { - self.fsm = None; - } else if chunk == 0 { - assert!(self.y > 0); - self.y = usize::MAX; - return; - } - } - } -} - -fn measure_headings(page_style: &CairoPageStyle, fsm_style: &CairoFsmStyle) -> [usize; 2] { - let surface = RecordingSurface::create(cairo::Content::Color, None).unwrap(); - let context = Context::new(&surface).unwrap(); - - let mut heading_heights = Vec::with_capacity(2); - for heading in &page_style.headings { - let mut height = render_heading( - &context, - &fsm_style.font, - heading, - -1, - fsm_style.size[Axis2::X], - 0, - fsm_style.font_resolution, - ); - if height > 0 { - height += fsm_style.object_spacing; - } - heading_heights.push(height); - } - heading_heights.try_into().unwrap() -} - -fn render_heading( - context: &Context, - font: &FontDescription, - heading: &Heading, - _page_number: i32, - width: usize, - base_y: usize, - font_resolution: f64, -) -> usize { - let pangocairo_context = pangocairo::functions::create_context(context); - pangocairo::functions::context_set_resolution(&pangocairo_context, font_resolution); - let layout = Layout::new(&pangocairo_context); - layout.set_font_description(Some(font)); - - let mut y = 0; - for paragraph in &heading.0 { - // XXX substitute heading variables - layout.set_markup(¶graph.markup); - - layout.set_alignment(horz_align_to_pango(paragraph.horz_align)); - layout.set_width(width as i32); - - context.save().unwrap(); - context.translate(0.0, xr_to_pt(y + base_y)); - pangocairo::functions::show_layout(context, &layout); - context.restore().unwrap(); - - y += layout.height() as usize; - } - y -} diff --git a/rust/pspp/src/output/csv.rs b/rust/pspp/src/output/csv.rs deleted file mode 100644 index 0eded20bdb..0000000000 --- a/rust/pspp/src/output/csv.rs +++ /dev/null @@ -1,230 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use std::{ - borrow::Cow, - fmt::Display, - fs::File, - io::{BufWriter, Error, Write}, - path::PathBuf, - sync::Arc, -}; - -use serde::{ - Deserialize, Deserializer, Serialize, - de::{Unexpected, Visitor}, -}; - -use crate::output::pivot::Coord2; - -use super::{Details, Item, TextType, driver::Driver, pivot::PivotTable, table::Table}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct CsvConfig { - file: PathBuf, - #[serde(flatten)] - options: CsvOptions, -} - -pub struct CsvDriver { - file: BufWriter, - options: CsvOptions, - - /// Number of items written so far. - n_items: usize, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -#[serde(default)] -struct CsvOptions { - #[serde(deserialize_with = "deserialize_ascii_char")] - quote: u8, - delimiter: u8, -} - -fn deserialize_ascii_char<'de, D>(deserializer: D) -> Result -where - D: Deserializer<'de>, -{ - struct AsciiCharVisitor; - impl<'de> Visitor<'de> for AsciiCharVisitor { - type Value = u8; - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - write!(f, "a single ASCII character") - } - fn visit_str(self, s: &str) -> Result - where - E: serde::de::Error, - { - if s.len() == 1 { - Ok(s.chars().next().unwrap() as u8) - } else { - Err(serde::de::Error::invalid_value(Unexpected::Str(s), &self)) - } - } - } - deserializer.deserialize_char(AsciiCharVisitor) -} - -impl Default for CsvOptions { - fn default() -> Self { - Self { - quote: b'"', - delimiter: b',', - } - } -} - -impl CsvOptions { - fn byte_needs_quoting(&self, b: u8) -> bool { - b == b'\r' || b == b'\n' || b == self.quote || b == self.delimiter - } - - fn string_needs_quoting(&self, s: &str) -> bool { - s.bytes().any(|b| self.byte_needs_quoting(b)) - } -} - -struct CsvField<'a> { - text: &'a str, - options: CsvOptions, -} - -impl<'a> CsvField<'a> { - fn new(text: &'a str, options: CsvOptions) -> Self { - Self { text, options } - } -} - -impl Display for CsvField<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.options.string_needs_quoting(self.text) { - let quote = self.options.quote as char; - write!(f, "{quote}")?; - for c in self.text.chars() { - if c == quote { - write!(f, "{c}")?; - } - write!(f, "{c}")?; - } - write!(f, "{quote}") - } else { - write!(f, "{}", self.text) - } - } -} - -impl CsvDriver { - pub fn new(config: &CsvConfig) -> std::io::Result { - Ok(Self { - file: BufWriter::new(File::create(&config.file)?), - options: config.options, - n_items: 0, - }) - } - - fn start_item(&mut self) { - if self.n_items > 0 { - writeln!(&mut self.file).unwrap(); - } - self.n_items += 1; - } - - fn output_table_layer(&mut self, pt: &PivotTable, layer: &[usize]) -> Result<(), Error> { - let output = pt.output(layer, true); - self.start_item(); - - self.output_table(pt, output.title.as_ref(), Some("Table"))?; - self.output_table(pt, output.layers.as_ref(), Some("Layer"))?; - self.output_table(pt, Some(&output.body), None)?; - self.output_table(pt, output.caption.as_ref(), Some("Caption"))?; - self.output_table(pt, output.footnotes.as_ref(), Some("Footnote"))?; - Ok(()) - } - - fn output_table( - &mut self, - pivot_table: &PivotTable, - table: Option<&Table>, - leader: Option<&str>, - ) -> Result<(), Error> { - let Some(table) = table else { - return Ok(()); - }; - - for y in 0..table.n.y() { - for x in 0..table.n.x() { - if x > 0 { - write!(&mut self.file, "{}", self.options.delimiter as char)?; - } - - let coord = Coord2::new(x, y); - let content = table.get(coord); - if content.is_top_left() { - let display = content.inner().value.display(pivot_table); - let s = match leader { - Some(leader) if x == 0 && y == 0 => format!("{leader}: {display}"), - _ => display.to_string(), - }; - write!(&mut self.file, "{}", CsvField::new(&s, self.options))?; - } - } - writeln!(&mut self.file)?; - } - - Ok(()) - } -} - -impl Driver for CsvDriver { - fn name(&self) -> Cow<'static, str> { - Cow::from("csv") - } - - fn write(&mut self, item: &Arc) { - // todo: error handling (should not unwrap) - match &item.details { - Details::Chart | Details::Image | Details::Group(_) => (), - Details::Message(diagnostic) => { - self.start_item(); - let text = diagnostic.to_string(); - writeln!(&mut self.file, "{}", CsvField::new(&text, self.options)).unwrap(); - } - Details::Table(pivot_table) => { - for layer in pivot_table.layers(true) { - self.output_table_layer(pivot_table, &layer).unwrap(); - } - } - Details::PageBreak => { - self.start_item(); - writeln!(&mut self.file).unwrap(); - } - Details::Text(text) => match text.type_ { - TextType::Syntax | TextType::PageTitle => (), - TextType::Title | TextType::Log => { - self.start_item(); - for line in text.content.display(()).to_string().lines() { - writeln!(&mut self.file, "{}", CsvField::new(line, self.options)).unwrap(); - } - } - }, - } - } - - fn flush(&mut self) { - let _ = self.file.flush(); - } -} diff --git a/rust/pspp/src/output/driver.rs b/rust/pspp/src/output/driver.rs deleted file mode 100644 index 6015ed8284..0000000000 --- a/rust/pspp/src/output/driver.rs +++ /dev/null @@ -1,164 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use std::{borrow::Cow, path::Path, sync::Arc}; - -use clap::ValueEnum; -use serde::{Deserialize, Serialize}; - -use crate::output::{ - cairo::{CairoConfig, CairoDriver}, - csv::{CsvConfig, CsvDriver}, - html::{HtmlConfig, HtmlDriver}, - json::{JsonConfig, JsonDriver}, - spv::{SpvConfig, SpvDriver}, - text::{TextConfig, TextDriver}, -}; - -use super::{Item, page::PageSetup}; - -// An output driver. -pub trait Driver { - fn name(&self) -> Cow<'static, str>; - - fn write(&mut self, item: &Arc); - - /// Returns false if the driver doesn't support page setup. - fn setup(&mut self, page_setup: &PageSetup) -> bool { - let _ = page_setup; - false - } - - /// Ensures that anything written with [Self::write] has been displayed. - /// - /// This is called from the text-based UI before showing the command prompt, - /// to ensure that the user has actually been shown any preceding output If - /// it doesn't make sense for this driver to be used this way, then this - /// function need not do anything. - fn flush(&mut self) {} - - /// Ordinarily, the core driver code will skip passing hidden output items - /// to [Self::write]. If this returns true, the core driver hands them to - /// the driver to let it handle them itself. - fn handles_show(&self) -> bool { - false - } - - /// Ordinarily, the core driver code will flatten groups of output items - /// before passing them to [Self::write]. If this returns true, the core - /// driver code leaves them in place for the driver to handle. - fn handles_groups(&self) -> bool { - false - } -} - -impl Driver for Box { - fn name(&self) -> Cow<'static, str> { - (**self).name() - } - - fn write(&mut self, item: &Arc) { - (**self).write(item); - } - - fn setup(&mut self, page_setup: &PageSetup) -> bool { - (**self).setup(page_setup) - } - - fn flush(&mut self) { - (**self).flush(); - } - - fn handles_show(&self) -> bool { - (**self).handles_show() - } - - fn handles_groups(&self) -> bool { - (**self).handles_groups() - } -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(tag = "driver", rename_all = "snake_case")] -pub enum Config { - Text(TextConfig), - Pdf(CairoConfig), - Html(HtmlConfig), - Json(JsonConfig), - Csv(CsvConfig), - Spv(SpvConfig), -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, ValueEnum)] -#[serde(rename_all = "snake_case")] -pub enum DriverType { - Text, - Pdf, - Html, - Csv, - Json, - Spv, -} - -impl dyn Driver { - pub fn new(config: &Config) -> anyhow::Result> { - match config { - Config::Text(text_config) => Ok(Box::new(TextDriver::new(text_config)?)), - Config::Pdf(cairo_config) => Ok(Box::new(CairoDriver::new(cairo_config)?)), - Config::Html(html_config) => Ok(Box::new(HtmlDriver::new(html_config)?)), - Config::Csv(csv_config) => Ok(Box::new(CsvDriver::new(csv_config)?)), - Config::Json(json_config) => Ok(Box::new(JsonDriver::new(json_config)?)), - Config::Spv(spv_config) => Ok(Box::new(SpvDriver::new(spv_config)?)), - } - } - - pub fn driver_type_from_filename(file: impl AsRef) -> Option<&'static str> { - match file.as_ref().extension()?.to_str()? { - "txt" | "text" => Some("text"), - "pdf" => Some("pdf"), - "htm" | "html" => Some("html"), - "csv" => Some("csv"), - "json" => Some("json"), - "spv" => Some("spv"), - _ => None, - } - } -} - -#[cfg(test)] -mod tests { - use serde::Serialize; - - use crate::output::driver::Config; - - #[test] - fn toml() { - let config = r#"driver = "text" -file = "filename.text" -"#; - let toml: Config = toml::from_str(config).unwrap(); - println!("{}", toml::to_string_pretty(&toml).unwrap()); - - #[derive(Serialize)] - struct Map<'a> { - file: &'a str, - } - println!( - "{}", - toml::to_string_pretty(&Map { file: "filename" }).unwrap() - ); - } -} diff --git a/rust/pspp/src/output/drivers.rs b/rust/pspp/src/output/drivers.rs new file mode 100644 index 0000000000..9c03cd3561 --- /dev/null +++ b/rust/pspp/src/output/drivers.rs @@ -0,0 +1,173 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use std::{borrow::Cow, path::Path, sync::Arc}; + +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; + +use super::{Item, page::PageSetup}; + +pub mod cairo; +use cairo::{CairoConfig, CairoDriver}; + +pub mod csv; +use csv::{CsvConfig, CsvDriver}; + +pub mod html; +use html::{HtmlConfig, HtmlDriver}; + +pub mod json; +use json::{JsonConfig, JsonDriver}; + +pub mod spv; +use spv::{SpvConfig, SpvDriver}; + +pub mod text; +use text::{TextConfig, TextDriver}; + +// An output driver. +pub trait Driver { + fn name(&self) -> Cow<'static, str>; + + fn write(&mut self, item: &Arc); + + /// Returns false if the driver doesn't support page setup. + fn setup(&mut self, page_setup: &PageSetup) -> bool { + let _ = page_setup; + false + } + + /// Ensures that anything written with [Self::write] has been displayed. + /// + /// This is called from the text-based UI before showing the command prompt, + /// to ensure that the user has actually been shown any preceding output If + /// it doesn't make sense for this driver to be used this way, then this + /// function need not do anything. + fn flush(&mut self) {} + + /// Ordinarily, the core driver code will skip passing hidden output items + /// to [Self::write]. If this returns true, the core driver hands them to + /// the driver to let it handle them itself. + fn handles_show(&self) -> bool { + false + } + + /// Ordinarily, the core driver code will flatten groups of output items + /// before passing them to [Self::write]. If this returns true, the core + /// driver code leaves them in place for the driver to handle. + fn handles_groups(&self) -> bool { + false + } +} + +impl Driver for Box { + fn name(&self) -> Cow<'static, str> { + (**self).name() + } + + fn write(&mut self, item: &Arc) { + (**self).write(item); + } + + fn setup(&mut self, page_setup: &PageSetup) -> bool { + (**self).setup(page_setup) + } + + fn flush(&mut self) { + (**self).flush(); + } + + fn handles_show(&self) -> bool { + (**self).handles_show() + } + + fn handles_groups(&self) -> bool { + (**self).handles_groups() + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "driver", rename_all = "snake_case")] +pub enum Config { + Text(TextConfig), + Pdf(CairoConfig), + Html(HtmlConfig), + Json(JsonConfig), + Csv(CsvConfig), + Spv(SpvConfig), +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, ValueEnum)] +#[serde(rename_all = "snake_case")] +pub enum DriverType { + Text, + Pdf, + Html, + Csv, + Json, + Spv, +} + +impl dyn Driver { + pub fn new(config: &Config) -> anyhow::Result> { + match config { + Config::Text(text_config) => Ok(Box::new(TextDriver::new(text_config)?)), + Config::Pdf(cairo_config) => Ok(Box::new(CairoDriver::new(cairo_config)?)), + Config::Html(html_config) => Ok(Box::new(HtmlDriver::new(html_config)?)), + Config::Csv(csv_config) => Ok(Box::new(CsvDriver::new(csv_config)?)), + Config::Json(json_config) => Ok(Box::new(JsonDriver::new(json_config)?)), + Config::Spv(spv_config) => Ok(Box::new(SpvDriver::new(spv_config)?)), + } + } + + pub fn driver_type_from_filename(file: impl AsRef) -> Option<&'static str> { + match file.as_ref().extension()?.to_str()? { + "txt" | "text" => Some("text"), + "pdf" => Some("pdf"), + "htm" | "html" => Some("html"), + "csv" => Some("csv"), + "json" => Some("json"), + "spv" => Some("spv"), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use serde::Serialize; + + use crate::output::drivers::Config; + + #[test] + fn toml() { + let config = r#"driver = "text" +file = "filename.text" +"#; + let toml: Config = toml::from_str(config).unwrap(); + println!("{}", toml::to_string_pretty(&toml).unwrap()); + + #[derive(Serialize)] + struct Map<'a> { + file: &'a str, + } + println!( + "{}", + toml::to_string_pretty(&Map { file: "filename" }).unwrap() + ); + } +} diff --git a/rust/pspp/src/output/drivers/cairo.rs b/rust/pspp/src/output/drivers/cairo.rs new file mode 100644 index 0000000000..71d15df078 --- /dev/null +++ b/rust/pspp/src/output/drivers/cairo.rs @@ -0,0 +1,52 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use pango::SCALE; + +use crate::output::pivot::HorzAlign; + +mod driver; +pub mod fsm; +pub mod pager; + +pub use driver::{CairoConfig, CairoDriver}; + +/// Conversion from 1/96" units ("pixels") to Cairo/Pango units. +fn px_to_xr(x: usize) -> usize { + x * 3 * (SCALE as usize * 72 / 96) / 3 +} + +fn xr_to_pt(x: usize) -> f64 { + x as f64 / SCALE as f64 +} + +fn horz_align_to_pango(horz_align: HorzAlign) -> pango::Alignment { + match horz_align { + HorzAlign::Right | HorzAlign::Decimal { .. } => pango::Alignment::Right, + HorzAlign::Left => pango::Alignment::Left, + HorzAlign::Center => pango::Alignment::Center, + } +} + +#[cfg(test)] +mod tests { + use crate::output::drivers::cairo::{CairoConfig, CairoDriver}; + + #[test] + fn create() { + CairoDriver::new(&CairoConfig::new("test.pdf")).unwrap(); + } +} diff --git a/rust/pspp/src/output/drivers/cairo/driver.rs b/rust/pspp/src/output/drivers/cairo/driver.rs new file mode 100644 index 0000000000..2bfd4be85e --- /dev/null +++ b/rust/pspp/src/output/drivers/cairo/driver.rs @@ -0,0 +1,164 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use std::{ + borrow::Cow, + path::{Path, PathBuf}, + sync::Arc, +}; + +use cairo::{Context, PdfSurface}; +use enum_map::{EnumMap, enum_map}; +use pango::SCALE; +use serde::{Deserialize, Serialize}; + +use crate::output::{ + Item, + drivers::{ + Driver, + cairo::{ + fsm::{CairoFsmStyle, parse_font_style}, + pager::{CairoPageStyle, CairoPager}, + }, + }, + page::PageSetup, + pivot::{Color, Coord2, FontStyle}, +}; + +use crate::output::pivot::Axis2; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct CairoConfig { + /// Output file name. + pub file: PathBuf, + + /// Page setup. + pub page_setup: Option, +} + +impl CairoConfig { + pub fn new(path: impl AsRef) -> Self { + Self { + file: path.as_ref().to_path_buf(), + page_setup: None, + } + } +} + +pub struct CairoDriver { + fsm_style: Arc, + page_style: Arc, + pager: Option, + surface: PdfSurface, +} + +impl CairoDriver { + pub fn new(config: &CairoConfig) -> cairo::Result { + fn scale(inches: f64) -> usize { + (inches * 72.0 * SCALE as f64).max(0.0).round() as usize + } + + let default_page_setup; + let page_setup = match &config.page_setup { + Some(page_setup) => page_setup, + None => { + default_page_setup = PageSetup::default(); + &default_page_setup + } + }; + let printable = page_setup.printable_size(); + let page_style = CairoPageStyle { + margins: EnumMap::from_fn(|axis| { + [ + scale(page_setup.margins[axis][0]), + scale(page_setup.margins[axis][1]), + ] + }), + headings: page_setup.headings.clone(), + initial_page_number: page_setup.initial_page_number, + }; + let size = Coord2::new(scale(printable[Axis2::X]), scale(printable[Axis2::Y])); + let font = FontStyle { + bold: false, + italic: false, + underline: false, + markup: false, + font: "Sans Serif".into(), + fg: [Color::BLACK, Color::BLACK], + bg: [Color::WHITE, Color::WHITE], + size: 10, + }; + let font = parse_font_style(&font); + let fsm_style = CairoFsmStyle { + size, + min_break: enum_map! { + Axis2::X => size[Axis2::X] / 2, + Axis2::Y => size[Axis2::Y] / 2, + }, + font, + fg: Color::BLACK, + use_system_colors: false, + object_spacing: scale(page_setup.object_spacing), + font_resolution: 72.0, + }; + let surface = PdfSurface::new( + page_setup.paper[Axis2::X] * 72.0, + page_setup.paper[Axis2::Y] * 72.0, + &config.file, + )?; + Ok(Self { + fsm_style: Arc::new(fsm_style), + page_style: Arc::new(page_style), + pager: None, + surface, + }) + } +} + +impl Driver for CairoDriver { + fn name(&self) -> Cow<'static, str> { + Cow::from("cairo") + } + + fn write(&mut self, item: &Arc) { + let pager = self.pager.get_or_insert_with(|| { + let mut pager = CairoPager::new(self.page_style.clone(), self.fsm_style.clone()); + pager.add_page(Context::new(&self.surface).unwrap()); + pager + }); + pager.add_item(item.clone()); + dbg!(); + while pager.needs_new_page() { + dbg!(); + pager.finish_page(); + let context = Context::new(&self.surface).unwrap(); + context.show_page().unwrap(); + pager.add_page(context); + } + dbg!(); + } +} + +impl Drop for CairoDriver { + fn drop(&mut self) { + dbg!(); + if self.pager.is_some() { + dbg!(); + let context = Context::new(&self.surface).unwrap(); + context.show_page().unwrap(); + } + } +} diff --git a/rust/pspp/src/output/drivers/cairo/fsm.rs b/rust/pspp/src/output/drivers/cairo/fsm.rs new file mode 100644 index 0000000000..d4bbb01aca --- /dev/null +++ b/rust/pspp/src/output/drivers/cairo/fsm.rs @@ -0,0 +1,762 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use std::{cmp::min, f64::consts::PI, fmt::Write, ops::DerefMut, sync::Arc}; + +use cairo::Context; +use enum_map::{EnumMap, enum_map}; +use itertools::Itertools; +use pango::{ + AttrFloat, AttrFontDesc, AttrInt, AttrList, Attribute, FontDescription, FontMask, Layout, + SCALE, SCALE_SMALL, Underline, Weight, parse_markup, +}; +use pangocairo::functions::show_layout; +use smallvec::{SmallVec, smallvec}; + +use crate::output::drivers::cairo::{horz_align_to_pango, px_to_xr, xr_to_pt}; +use crate::output::pivot::{Axis2, BorderStyle, Coord2, FontStyle, HorzAlign, Rect2, Stroke}; +use crate::output::render::{Device, Extreme, Pager, Params}; +use crate::output::table::DrawCell; +use crate::output::{Details, Item}; +use crate::output::{pivot::Color, table::Content}; + +/// Width of an ordinary line. +const LINE_WIDTH: usize = LINE_SPACE / 2; + +/// Space between double lines. +const LINE_SPACE: usize = SCALE as usize; + +/// Conversion from 1/96" units ("pixels") to Cairo/Pango units. +fn pxf_to_xr(x: f64) -> usize { + (x * (SCALE as f64 * 72.0 / 96.0)).round() as usize +} + +#[derive(Clone, Debug)] +pub struct CairoFsmStyle { + /// Page size. + pub size: Coord2, + + /// Minimum cell size to allow breaking. + pub min_break: EnumMap, + + /// The basic font. + pub font: FontDescription, + + /// Foreground color. + pub fg: Color, + + /// Use system colors? + pub use_system_colors: bool, + + /// Vertical space between different output items. + pub object_spacing: usize, + + /// Resolution, in units per inch, used for measuring font "points": + /// + /// - 72.0 if 1 pt is one device unit, e.g. for rendering to a surface + /// created by [cairo::PsSurface::new] with its default transformation + /// matrix of 72 units/inch.p + /// + /// - 96.0 is traditional for a screen-based surface. + pub font_resolution: f64, +} + +impl CairoFsmStyle { + fn new_layout(&self, context: &Context) -> Layout { + let pangocairo_context = pangocairo::functions::create_context(context); + pangocairo::functions::context_set_resolution(&pangocairo_context, self.font_resolution); + Layout::new(&pangocairo_context) + } +} + +pub struct CairoFsm { + style: Arc, + params: Params, + item: Arc, + layer_iterator: Option>>>, + pager: Option, +} + +impl CairoFsm { + pub fn new( + style: Arc, + printing: bool, + context: &Context, + item: Arc, + ) -> Self { + let params = Params { + size: style.size, + font_size: { + let layout = style.new_layout(context); + layout.set_font_description(Some(&style.font)); + layout.set_text("0"); + let char_size = layout.size(); + enum_map! { + Axis2::X => char_size.0.max(0) as usize, + Axis2::Y => char_size.1.max(0) as usize + } + }, + line_widths: enum_map! { + Stroke::None => 0, + Stroke::Solid | Stroke::Dashed => LINE_WIDTH, + Stroke::Thick => LINE_WIDTH * 2, + Stroke::Thin => LINE_WIDTH / 2, + Stroke::Double => LINE_WIDTH * 2 + LINE_SPACE, + }, + px_size: Some(px_to_xr(1)), + min_break: style.min_break, + supports_margins: true, + rtl: false, + printing, + can_adjust_break: false, // XXX + can_scale: true, + }; + let device = CairoDevice { + style: &style, + params: ¶ms, + context, + }; + let (layer_iterator, pager) = match &item.details { + Details::Table(pivot_table) => { + let mut layer_iterator = pivot_table.layers(printing); + let layer_indexes = layer_iterator.next(); + ( + Some(layer_iterator), + Some(Pager::new( + &device, + pivot_table, + layer_indexes.as_ref().map(|indexes| indexes.as_slice()), + )), + ) + } + _ => (None, None), + }; + Self { + style, + params, + item, + layer_iterator, + pager, + } + } + + pub fn draw_slice(&mut self, context: &Context, space: usize) -> usize { + debug_assert!(self.params.printing); + + context.save().unwrap(); + let used = match &self.item.details { + Details::Table(_) => self.draw_table(context, space), + _ => todo!(), + }; + context.restore().unwrap(); + + used + } + + fn draw_table(&mut self, context: &Context, space: usize) -> usize { + let Details::Table(pivot_table) = &self.item.details else { + unreachable!() + }; + let Some(pager) = &mut self.pager else { + return 0; + }; + let mut device = CairoDevice { + style: &self.style, + params: &self.params, + context, + }; + let mut used = pager.draw_next(&mut device, space); + if !pager.has_next(&device) { + match self.layer_iterator.as_mut().unwrap().next() { + Some(layer_indexes) => { + self.pager = Some(Pager::new( + &device, + pivot_table, + Some(layer_indexes.as_slice()), + )); + if pivot_table.look.paginate_layers { + used = space; + } else { + used += self.style.object_spacing; + } + } + _ => { + self.pager = None; + } + } + } + used.min(space) + } + + pub fn is_done(&self) -> bool { + match &self.item.details { + Details::Table(_) => self.pager.is_none(), + _ => todo!(), + } + } +} + +fn xr_clip(context: &Context, clip: &Rect2) { + if clip[Axis2::X].end != usize::MAX || clip[Axis2::Y].end != usize::MAX { + let x0 = xr_to_pt(clip[Axis2::X].start); + let y0 = xr_to_pt(clip[Axis2::Y].start); + let x1 = xr_to_pt(clip[Axis2::X].end); + let y1 = xr_to_pt(clip[Axis2::Y].end); + context.rectangle(x0, y0, x1 - x0, y1 - y0); + context.clip(); + } +} + +fn xr_set_color(context: &Context, color: &Color) { + fn as_frac(x: u8) -> f64 { + x as f64 / 255.0 + } + + context.set_source_rgba( + as_frac(color.r), + as_frac(color.g), + as_frac(color.b), + as_frac(color.alpha), + ); +} + +fn xr_fill_rectangle(context: &Context, rectangle: Rect2) { + context.new_path(); + context.set_line_width(xr_to_pt(LINE_WIDTH)); + + let x0 = xr_to_pt(rectangle[Axis2::X].start); + let y0 = xr_to_pt(rectangle[Axis2::Y].start); + let width = xr_to_pt(rectangle[Axis2::X].len()); + let height = xr_to_pt(rectangle[Axis2::Y].len()); + context.rectangle(x0, y0, width, height); + context.fill().unwrap(); +} + +fn margin(cell: &DrawCell, axis: Axis2) -> usize { + px_to_xr( + cell.style.cell_style.margins[axis] + .iter() + .sum::() + .max(0) as usize, + ) +} + +pub fn parse_font_style(font_style: &FontStyle) -> FontDescription { + let font = font_style.font.as_str(); + let font = if font.eq_ignore_ascii_case("Monospaced") { + "Monospace" + } else { + font + }; + let mut font_desc = FontDescription::from_string(font); + if !font_desc.set_fields().contains(FontMask::SIZE) { + let default_size = if font_style.size != 0 { + font_style.size * 1000 + } else { + 10_000 + }; + font_desc.set_size(((default_size as f64 / 1000.0) * (SCALE as f64)) as i32); + } + font_desc.set_weight(if font_style.bold { + Weight::Bold + } else { + Weight::Normal + }); + font_desc.set_style(if font_style.italic { + pango::Style::Italic + } else { + pango::Style::Normal + }); + font_desc +} + +/// Deal with an oddity of the Unicode line-breaking algorithm (or perhaps in +/// Pango's implementation of it): it will break after a period or a comma that +/// precedes a digit, e.g. in `.000` it will break after the period. This code +/// looks for such a situation and inserts a U+2060 WORD JOINER to prevent the +/// break. +/// +/// This isn't necessary when the decimal point is between two digits +/// (e.g. `0.000` won't be broken) or when the display width is not limited so +/// that word wrapping won't happen. +/// +/// It isn't necessary to look for more than one period or comma, as would +/// happen with grouping like `1,234,567.89` or `1.234.567,89` because if groups +/// are present then there will always be a digit on both sides of every period +/// and comma. +fn avoid_decimal_split(mut s: String) -> String { + if let Some(position) = s.find(['.', ',']) { + let followed_by_digit = s[position + 1..] + .chars() + .next() + .is_some_and(|c| c.is_ascii_digit()); + let not_preceded_by_digit = s[..position] + .chars() + .next_back() + .is_none_or(|c| !c.is_ascii_digit()); + if followed_by_digit && not_preceded_by_digit { + s.insert(position + 1, '\u{2060}'); + } + } + s +} + +struct CairoDevice<'a> { + style: &'a CairoFsmStyle, + params: &'a Params, + context: &'a Context, +} + +impl CairoDevice<'_> { + fn layout_cell(&self, cell: &DrawCell, mut bb: Rect2, clip: &Rect2) -> Coord2 { + // XXX rotation + //let h = if cell.rotate { Axis2::Y } else { Axis2::X }; + + let layout = self.style.new_layout(self.context); + + let cell_font = if !cell.style.font_style.font.is_empty() { + Some(parse_font_style(&cell.style.font_style)) + } else { + None + }; + let font = cell_font.as_ref().unwrap_or(&self.style.font); + layout.set_font_description(Some(font)); + + let (body, suffixes) = cell.display().split_suffixes(); + let horz_align = cell.horz_align(&body); + let body = body.to_string(); + + match horz_align { + HorzAlign::Decimal { offset, decimal } if !cell.rotate => { + let decimal_position = if let Some(position) = body.rfind(char::from(decimal)) { + layout.set_text(&body[position..]); + layout.set_width(-1); + layout.size().0.max(0) as usize + } else { + 0 + }; + bb[Axis2::X].end -= pxf_to_xr(offset).saturating_sub(decimal_position); + } + _ => (), + } + + let mut attrs = None; + let mut body = if cell.style.font_style.markup { + match parse_markup(&body, 0 as char) { + Ok((markup_attrs, string, _accel)) => { + attrs = Some(markup_attrs); + string.into() + } + Err(_) => body, + } + } else { + avoid_decimal_split(body) + }; + + if cell.style.font_style.underline { + attrs + .get_or_insert_default() + .insert(AttrInt::new_underline(Underline::Single)); + } + + if !suffixes.is_empty() { + let subscript_ofs = body.len(); + #[allow(unstable_name_collisions)] + body.extend(suffixes.subscripts().intersperse(",")); + let has_subscripts = subscript_ofs != body.len(); + + let footnote_ofs = body.len(); + for (index, footnote) in suffixes.footnotes().enumerate() { + if index > 0 { + body.push(','); + } + write!(&mut body, "{footnote}").unwrap(); + } + let has_footnotes = footnote_ofs != body.len(); + + // Allow footnote markers to occupy the right margin. That way, + // numbers in the column are still aligned. + if has_footnotes && horz_align == HorzAlign::Right { + // Measure the width of the footnote marker, so we know how much we + // need to make room for. + layout.set_text(&body[footnote_ofs..]); + + let footnote_attrs = AttrList::new(); + footnote_attrs.insert(AttrFloat::new_scale(SCALE_SMALL)); + footnote_attrs.insert(AttrInt::new_rise(3000)); + layout.set_attributes(Some(&footnote_attrs)); + let footnote_width = layout.size().0.max(0) as usize; + + // Bound the adjustment by the width of the right margin. + let right_margin = + px_to_xr(cell.style.cell_style.margins[Axis2::X][1].max(0) as usize); + let footnote_adjustment = min(footnote_width, right_margin); + + // Adjust the bounding box. + if cell.rotate { + bb[Axis2::X].end = bb[Axis2::X].end.saturating_sub(footnote_adjustment); + } else { + bb[Axis2::X].end = bb[Axis2::X].end.saturating_add(footnote_adjustment); + } + + // Clean up. + layout.set_attributes(None); + } + + fn with_start>(index: usize, mut attr: T) -> T { + attr.deref_mut().set_start_index(index.try_into().unwrap()); + attr + } + fn with_end>(index: usize, mut attr: T) -> T { + attr.deref_mut().set_end_index(index.try_into().unwrap()); + attr + } + + // Set attributes. + let attrs = attrs.get_or_insert_default(); + attrs.insert(with_start(subscript_ofs, AttrFontDesc::new(font))); + attrs.insert(with_start(subscript_ofs, AttrFloat::new_scale(SCALE_SMALL))); + if has_subscripts { + attrs.insert(with_start( + subscript_ofs, + with_end(footnote_ofs, AttrInt::new_rise(-3000)), + )); + } + if has_footnotes { + let rise = 3000; // XXX check look for superscript vs subscript + attrs.insert(with_start(footnote_ofs, AttrInt::new_rise(rise))); + } + } + + layout.set_attributes(attrs.as_ref()); + layout.set_text(&body); + layout.set_alignment(horz_align_to_pango(horz_align)); + if bb[Axis2::X].end == usize::MAX { + layout.set_width(-1); + } else { + layout.set_width(bb[Axis2::X].len() as i32); + } + + let size = layout.size(); + + if !clip.is_empty() { + self.context.save().unwrap(); + if !cell.rotate { + xr_clip(self.context, clip); + } + if cell.rotate { + let extra = bb[Axis2::X].len().saturating_sub(size.1.max(0) as usize); + let halign_offset = extra / 2; + self.context.translate( + xr_to_pt(bb[Axis2::X].start + halign_offset), + xr_to_pt(bb[Axis2::Y].end), + ); + self.context.rotate(-PI / 2.0); + } else { + self.context + .translate(xr_to_pt(bb[Axis2::X].start), xr_to_pt(bb[Axis2::Y].start)); + } + show_layout(self.context, &layout); + self.context.restore().unwrap(); + } + + layout.set_attributes(None); + + Coord2::new(size.0.max(0) as usize, size.1.max(0) as usize) + } + + fn do_draw_line( + &self, + x0: usize, + y0: usize, + x1: usize, + y1: usize, + stroke: Stroke, + color: Color, + ) { + self.context.new_path(); + self.context.set_line_width(xr_to_pt(match stroke { + Stroke::Thick => LINE_WIDTH * 2, + Stroke::Thin => LINE_WIDTH / 2, + _ => LINE_WIDTH, + })); + self.context.move_to(xr_to_pt(x0), xr_to_pt(y0)); + self.context.line_to(xr_to_pt(x1), xr_to_pt(y1)); + if !self.style.use_system_colors { + xr_set_color(self.context, &color); + } + if stroke == Stroke::Dashed { + self.context.set_dash(&[2.0], 0.0); + let _ = self.context.stroke(); + self.context.set_dash(&[], 0.0); + } else { + let _ = self.context.stroke(); + } + } +} + +impl Device for CairoDevice<'_> { + fn params(&self) -> &Params { + self.params + } + + fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap { + fn add_margins(cell: &DrawCell, width: usize) -> usize { + if width > 0 { + width + margin(cell, Axis2::X) + } else { + 0 + } + } + + /// An empty clipping rectangle. + fn clip() -> Rect2 { + Rect2::default() + } + + enum_map![ + Extreme::Min => { + let bb = Rect2::new(0..1, 0..usize::MAX); + add_margins(cell, self.layout_cell(cell, bb, &clip()).x()) + } + Extreme::Max => { + let bb = Rect2::new(0..usize::MAX, 0..usize::MAX); + add_margins(cell, self.layout_cell(cell, bb, &clip()).x()) + }, + ] + } + + fn measure_cell_height(&self, cell: &DrawCell, width: usize) -> usize { + let margins = &cell.style.cell_style.margins; + let bb = Rect2::new( + 0..width.saturating_sub(px_to_xr(margins[Axis2::X].len())), + 0..usize::MAX, + ); + self.layout_cell(cell, bb, &Rect2::default()).y() + margin(cell, Axis2::Y) + } + + fn adjust_break(&self, _cell: &Content, _size: Coord2) -> usize { + todo!() + } + + fn draw_line(&mut self, bb: Rect2, styles: EnumMap) { + let x0 = bb[Axis2::X].start; + let y0 = bb[Axis2::Y].start; + let x3 = bb[Axis2::X].end; + let y3 = bb[Axis2::Y].end; + + let top = styles[Axis2::X][0].stroke; + let bottom = styles[Axis2::X][1].stroke; + let left = styles[Axis2::Y][0].stroke; + let right = styles[Axis2::Y][1].stroke; + + let top_color = styles[Axis2::X][0].color; + let bottom_color = styles[Axis2::X][1].color; + let left_color = styles[Axis2::Y][0].color; + let right_color = styles[Axis2::Y][1].color; + + // The algorithm here is somewhat subtle, to allow it to handle + // all the kinds of intersections that we need. + // + // Three additional ordinates are assigned along the X axis. The first + // is `xc`, midway between `x0` and `x3`. The others are `x1` and `x2`; + // for a single vertical line these are equal to `xc`, and for a double + // vertical line they are the ordinates of the left and right half of + // the double line. + // + // `yc`, `y1`, and `y2` are assigned similarly along the Y axis. + // + // The following diagram shows the coordinate system and output for + // double top and bottom lines, single left line, and no right line: + // + // ``` + // x0 x1 xc x2 x3 + // y0 ________________________ + // | # # | + // | # # | + // | # # | + // | # # | + // | # # | + // y1 = y2 = yc |######### # | + // | # # | + // | # # | + // | # # | + // | # # | + // y3 |________#_____#_______| + // ``` + + // Offset from center of each line in a pair of double lines. + let double_line_ofs = (LINE_SPACE + LINE_WIDTH) / 2; + + // Are the lines along each axis single or double? (It doesn't make + // sense to have different kinds of line on the same axis, so we don't + // try to gracefully handle that case.) + let double_vert = top == Stroke::Double || bottom == Stroke::Double; + let double_horz = left == Stroke::Double || right == Stroke::Double; + + // When horizontal lines are doubled, the left-side line along `y1` + // normally runs from `x0` to `x2`, and the right-side line along `y1` + // from `x3` to `x1`. If the top-side line is also doubled, we shorten + // the `y1` lines, so that the left-side line runs only to `x1`, and the + // right-side line only to `x2`. Otherwise, the horizontal line at `y = + // y1` below would cut off the intersection, which looks ugly: + // + // ``` + // x0 x1 x2 x3 + // y0 ________________________ + // | # # | + // | # # | + // | # # | + // | # # | + // y1 |######### ########| + // | | + // | | + // y2 |######################| + // | | + // | | + // y3 |______________________| + // ``` + // + // It is more of a judgment call when the horizontal line is single. We + // choose to cut off the line anyhow, as shown in the first diagram + // above. + let shorten_y1_lines = top == Stroke::Double; + let shorten_y2_lines = bottom == Stroke::Double; + let shorten_yc_line = shorten_y1_lines && shorten_y2_lines; + let horz_line_ofs = if double_vert { double_line_ofs } else { 0 }; + let xc = (x0 + x3) / 2; + let x1 = xc - horz_line_ofs; + let x2 = xc + horz_line_ofs; + + let shorten_x1_lines = left == Stroke::Double; + let shorten_x2_lines = right == Stroke::Double; + let shorten_xc_line = shorten_x1_lines && shorten_x2_lines; + let vert_line_ofs = if double_horz { double_line_ofs } else { 0 }; + let yc = (y0 + y3) / 2; + let y1 = yc - vert_line_ofs; + let y2 = yc + vert_line_ofs; + + let horz_lines: SmallVec<[_; 2]> = if double_horz { + smallvec![(y1, shorten_y1_lines), (y2, shorten_y2_lines)] + } else { + smallvec![(yc, shorten_yc_line)] + }; + for (y, shorten) in horz_lines { + if left != Stroke::None + && right != Stroke::None + && !shorten + && left_color == right_color + { + self.do_draw_line(x0, y, x3, y, left, left_color); + } else { + if left != Stroke::None { + self.do_draw_line(x0, y, if shorten { x1 } else { x2 }, y, left, left_color); + } + if right != Stroke::None { + self.do_draw_line(if shorten { x2 } else { x1 }, y, x3, y, right, right_color); + } + } + } + + let vert_lines: SmallVec<[_; 2]> = if double_vert { + smallvec![(x1, shorten_x1_lines), (x2, shorten_x2_lines)] + } else { + smallvec![(xc, shorten_xc_line)] + }; + for (x, shorten) in vert_lines { + if top != Stroke::None + && bottom != Stroke::None + && !shorten + && top_color == bottom_color + { + self.do_draw_line(x, y0, x, y3, top, top_color); + } else { + if top != Stroke::None { + self.do_draw_line(x, y0, x, if shorten { y1 } else { y2 }, top, top_color); + } + if bottom != Stroke::None { + self.do_draw_line( + x, + if shorten { y2 } else { y1 }, + x, + y3, + bottom, + bottom_color, + ); + } + } + } + } + + fn draw_cell( + &mut self, + draw_cell: &DrawCell, + alternate_row: bool, + mut bb: Rect2, + valign_offset: usize, + spill: EnumMap, + clip: &Rect2, + ) { + let fg = &draw_cell.style.font_style.fg[alternate_row as usize]; + let bg = &draw_cell.style.font_style.bg[alternate_row as usize]; + + if (bg.r != 255 || bg.g != 255 || bg.b != 255) && bg.alpha != 0 { + self.context.save().unwrap(); + let bg_clip = Rect2::from_fn(|axis| { + let start = if bb[axis].start == clip[axis].start { + clip[axis].start.saturating_sub(spill[axis][0]) + } else { + clip[axis].start + }; + let end = if bb[axis].end == clip[axis].end { + clip[axis].end + spill[axis][1] + } else { + clip[axis].end + }; + start..end + }); + xr_clip(self.context, &bg_clip); + xr_set_color(self.context, bg); + let x0 = bb[Axis2::X].start.saturating_sub(spill[Axis2::X][0]); + let y0 = bb[Axis2::Y].start.saturating_sub(spill[Axis2::X][1]); + let x1 = bb[Axis2::X].end + spill[Axis2::X][1]; + let y1 = bb[Axis2::Y].end + spill[Axis2::Y][1]; + xr_fill_rectangle(self.context, Rect2::new(x0..x1, y0..y1)); + self.context.restore().unwrap(); + } + + if !self.style.use_system_colors { + xr_set_color(self.context, fg); + } + + self.context.save().unwrap(); + bb[Axis2::Y].start += valign_offset; + for axis in [Axis2::X, Axis2::Y] { + bb[axis].start += px_to_xr(draw_cell.style.cell_style.margins[axis][0].max(0) as usize); + bb[axis].end = bb[axis] + .end + .saturating_sub(draw_cell.style.cell_style.margins[axis][0].max(0) as usize); + } + if bb[Axis2::X].start < bb[Axis2::X].end && bb[Axis2::Y].start < bb[Axis2::Y].end { + self.layout_cell(draw_cell, bb, clip); + } + self.context.restore().unwrap(); + } + + fn scale(&mut self, factor: f64) { + self.context.scale(factor, factor); + } +} diff --git a/rust/pspp/src/output/drivers/cairo/pager.rs b/rust/pspp/src/output/drivers/cairo/pager.rs new file mode 100644 index 0000000000..3bc05f0503 --- /dev/null +++ b/rust/pspp/src/output/drivers/cairo/pager.rs @@ -0,0 +1,237 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use std::sync::Arc; + +use cairo::{Context, RecordingSurface}; +use enum_map::EnumMap; +use pango::{FontDescription, Layout}; + +use crate::output::{ + Item, ItemCursor, + drivers::cairo::{ + fsm::{CairoFsm, CairoFsmStyle}, + horz_align_to_pango, xr_to_pt, + }, + page::Heading, + pivot::Axis2, +}; + +#[derive(Clone, Debug)] +pub struct CairoPageStyle { + pub margins: EnumMap, + pub headings: [Heading; 2], + pub initial_page_number: i32, +} + +pub struct CairoPager { + page_style: Arc, + fsm_style: Arc, + page_index: i32, + heading_heights: [usize; 2], + iter: Option, + context: Option, + fsm: Option, + y: usize, +} + +impl CairoPager { + pub fn new(mut page_style: Arc, mut fsm_style: Arc) -> Self { + let heading_heights = measure_headings(&page_style, &fsm_style); + let total = heading_heights.iter().sum::(); + if (0..fsm_style.size[Axis2::Y]).contains(&total) { + let fsm_style = Arc::make_mut(&mut fsm_style); + let page_style = Arc::make_mut(&mut page_style); + #[allow(clippy::needless_range_loop)] + for i in 0..2 { + page_style.margins[Axis2::Y][i] += heading_heights[i]; + } + fsm_style.size[Axis2::Y] -= total; + } + Self { + page_style, + fsm_style, + page_index: 0, + heading_heights, + iter: None, + context: None, + fsm: None, + y: 0, + } + } + + pub fn add_page(&mut self, context: Context) { + assert!(self.context.is_none()); + context.save().unwrap(); + self.y = 0; + + context.translate( + xr_to_pt(self.page_style.margins[Axis2::X][0]), + xr_to_pt(self.page_style.margins[Axis2::Y][0]), + ); + + let page_number = self.page_index + self.page_style.initial_page_number; + self.page_index += 1; + + if self.heading_heights[0] > 0 { + render_heading( + &context, + &self.fsm_style.font, + &self.page_style.headings[0], + page_number, + self.fsm_style.size[Axis2::X], + 0, /* XXX*/ + self.fsm_style.font_resolution, + ); + } + if self.heading_heights[0] > 0 { + render_heading( + &context, + &self.fsm_style.font, + &self.page_style.headings[1], + page_number, + self.fsm_style.size[Axis2::X], + self.fsm_style.size[Axis2::Y] + self.fsm_style.object_spacing, + self.fsm_style.font_resolution, + ); + } + + self.context = Some(context); + self.run(); + } + + pub fn finish_page(&mut self) { + if let Some(context) = self.context.take() { + context.restore().unwrap(); + } + } + + pub fn needs_new_page(&mut self) -> bool { + if self.iter.is_some() + && (self.context.is_none() || self.y >= self.fsm_style.size[Axis2::Y]) + { + self.finish_page(); + true + } else { + false + } + } + + pub fn add_item(&mut self, item: Arc) { + self.iter = Some(ItemCursor::new(item)); + self.run(); + } + + fn run(&mut self) { + let Some(context) = self.context.as_ref().cloned() else { + return; + }; + if self.iter.is_none() || self.y >= self.fsm_style.size[Axis2::Y] { + return; + } + + loop { + // Make sure we've got an object to render. + let fsm = match &mut self.fsm { + Some(fsm) => fsm, + None => { + // If there are no remaining objects to render, then we're done. + let Some(iter) = self.iter.as_mut() else { + return; + }; + let Some(item) = iter.cur().cloned() else { + self.iter = None; + return; + }; + iter.next(); + self.fsm + .insert(CairoFsm::new(self.fsm_style.clone(), true, &context, item)) + } + }; + + // Prepare to render the current object. + let chunk = fsm.draw_slice( + &context, + self.fsm_style.size[Axis2::Y].saturating_sub(self.y), + ); + self.y += chunk + self.fsm_style.object_spacing; + context.translate(0.0, xr_to_pt(chunk + self.fsm_style.object_spacing)); + + if fsm.is_done() { + self.fsm = None; + } else if chunk == 0 { + assert!(self.y > 0); + self.y = usize::MAX; + return; + } + } + } +} + +fn measure_headings(page_style: &CairoPageStyle, fsm_style: &CairoFsmStyle) -> [usize; 2] { + let surface = RecordingSurface::create(cairo::Content::Color, None).unwrap(); + let context = Context::new(&surface).unwrap(); + + let mut heading_heights = Vec::with_capacity(2); + for heading in &page_style.headings { + let mut height = render_heading( + &context, + &fsm_style.font, + heading, + -1, + fsm_style.size[Axis2::X], + 0, + fsm_style.font_resolution, + ); + if height > 0 { + height += fsm_style.object_spacing; + } + heading_heights.push(height); + } + heading_heights.try_into().unwrap() +} + +fn render_heading( + context: &Context, + font: &FontDescription, + heading: &Heading, + _page_number: i32, + width: usize, + base_y: usize, + font_resolution: f64, +) -> usize { + let pangocairo_context = pangocairo::functions::create_context(context); + pangocairo::functions::context_set_resolution(&pangocairo_context, font_resolution); + let layout = Layout::new(&pangocairo_context); + layout.set_font_description(Some(font)); + + let mut y = 0; + for paragraph in &heading.0 { + // XXX substitute heading variables + layout.set_markup(¶graph.markup); + + layout.set_alignment(horz_align_to_pango(paragraph.horz_align)); + layout.set_width(width as i32); + + context.save().unwrap(); + context.translate(0.0, xr_to_pt(y + base_y)); + pangocairo::functions::show_layout(context, &layout); + context.restore().unwrap(); + + y += layout.height() as usize; + } + y +} diff --git a/rust/pspp/src/output/drivers/csv.rs b/rust/pspp/src/output/drivers/csv.rs new file mode 100644 index 0000000000..1a48eeaa30 --- /dev/null +++ b/rust/pspp/src/output/drivers/csv.rs @@ -0,0 +1,230 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use std::{ + borrow::Cow, + fmt::Display, + fs::File, + io::{BufWriter, Error, Write}, + path::PathBuf, + sync::Arc, +}; + +use serde::{ + Deserialize, Deserializer, Serialize, + de::{Unexpected, Visitor}, +}; + +use crate::output::{Item, drivers::Driver, pivot::Coord2}; + +use crate::output::{Details, TextType, pivot::PivotTable, table::Table}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CsvConfig { + file: PathBuf, + #[serde(flatten)] + options: CsvOptions, +} + +pub struct CsvDriver { + file: BufWriter, + options: CsvOptions, + + /// Number of items written so far. + n_items: usize, +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +struct CsvOptions { + #[serde(deserialize_with = "deserialize_ascii_char")] + quote: u8, + delimiter: u8, +} + +fn deserialize_ascii_char<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + struct AsciiCharVisitor; + impl<'de> Visitor<'de> for AsciiCharVisitor { + type Value = u8; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "a single ASCII character") + } + fn visit_str(self, s: &str) -> Result + where + E: serde::de::Error, + { + if s.len() == 1 { + Ok(s.chars().next().unwrap() as u8) + } else { + Err(serde::de::Error::invalid_value(Unexpected::Str(s), &self)) + } + } + } + deserializer.deserialize_char(AsciiCharVisitor) +} + +impl Default for CsvOptions { + fn default() -> Self { + Self { + quote: b'"', + delimiter: b',', + } + } +} + +impl CsvOptions { + fn byte_needs_quoting(&self, b: u8) -> bool { + b == b'\r' || b == b'\n' || b == self.quote || b == self.delimiter + } + + fn string_needs_quoting(&self, s: &str) -> bool { + s.bytes().any(|b| self.byte_needs_quoting(b)) + } +} + +struct CsvField<'a> { + text: &'a str, + options: CsvOptions, +} + +impl<'a> CsvField<'a> { + fn new(text: &'a str, options: CsvOptions) -> Self { + Self { text, options } + } +} + +impl Display for CsvField<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.options.string_needs_quoting(self.text) { + let quote = self.options.quote as char; + write!(f, "{quote}")?; + for c in self.text.chars() { + if c == quote { + write!(f, "{c}")?; + } + write!(f, "{c}")?; + } + write!(f, "{quote}") + } else { + write!(f, "{}", self.text) + } + } +} + +impl CsvDriver { + pub fn new(config: &CsvConfig) -> std::io::Result { + Ok(Self { + file: BufWriter::new(File::create(&config.file)?), + options: config.options, + n_items: 0, + }) + } + + fn start_item(&mut self) { + if self.n_items > 0 { + writeln!(&mut self.file).unwrap(); + } + self.n_items += 1; + } + + fn output_table_layer(&mut self, pt: &PivotTable, layer: &[usize]) -> Result<(), Error> { + let output = pt.output(layer, true); + self.start_item(); + + self.output_table(pt, output.title.as_ref(), Some("Table"))?; + self.output_table(pt, output.layers.as_ref(), Some("Layer"))?; + self.output_table(pt, Some(&output.body), None)?; + self.output_table(pt, output.caption.as_ref(), Some("Caption"))?; + self.output_table(pt, output.footnotes.as_ref(), Some("Footnote"))?; + Ok(()) + } + + fn output_table( + &mut self, + pivot_table: &PivotTable, + table: Option<&Table>, + leader: Option<&str>, + ) -> Result<(), Error> { + let Some(table) = table else { + return Ok(()); + }; + + for y in 0..table.n.y() { + for x in 0..table.n.x() { + if x > 0 { + write!(&mut self.file, "{}", self.options.delimiter as char)?; + } + + let coord = Coord2::new(x, y); + let content = table.get(coord); + if content.is_top_left() { + let display = content.inner().value.display(pivot_table); + let s = match leader { + Some(leader) if x == 0 && y == 0 => format!("{leader}: {display}"), + _ => display.to_string(), + }; + write!(&mut self.file, "{}", CsvField::new(&s, self.options))?; + } + } + writeln!(&mut self.file)?; + } + + Ok(()) + } +} + +impl Driver for CsvDriver { + fn name(&self) -> Cow<'static, str> { + Cow::from("csv") + } + + fn write(&mut self, item: &Arc) { + // todo: error handling (should not unwrap) + match &item.details { + Details::Chart | Details::Image | Details::Group(_) => (), + Details::Message(diagnostic) => { + self.start_item(); + let text = diagnostic.to_string(); + writeln!(&mut self.file, "{}", CsvField::new(&text, self.options)).unwrap(); + } + Details::Table(pivot_table) => { + for layer in pivot_table.layers(true) { + self.output_table_layer(pivot_table, &layer).unwrap(); + } + } + Details::PageBreak => { + self.start_item(); + writeln!(&mut self.file).unwrap(); + } + Details::Text(text) => match text.type_ { + TextType::Syntax | TextType::PageTitle => (), + TextType::Title | TextType::Log => { + self.start_item(); + for line in text.content.display(()).to_string().lines() { + writeln!(&mut self.file, "{}", CsvField::new(line, self.options)).unwrap(); + } + } + }, + } + } + + fn flush(&mut self) { + let _ = self.file.flush(); + } +} diff --git a/rust/pspp/src/output/drivers/html.rs b/rust/pspp/src/output/drivers/html.rs new file mode 100644 index 0000000000..2c5f0aebcc --- /dev/null +++ b/rust/pspp/src/output/drivers/html.rs @@ -0,0 +1,500 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use std::{ + borrow::Cow, + fmt::{Display, Write as _}, + fs::File, + io::Write, + path::PathBuf, + sync::Arc, +}; + +use serde::{Deserialize, Serialize}; +use smallstr::SmallString; + +use crate::output::{ + Details, Item, + drivers::Driver, + pivot::{Axis2, BorderStyle, Color, Coord2, HorzAlign, PivotTable, Rect2, Stroke, VertAlign}, + table::{DrawCell, Table}, +}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct HtmlConfig { + file: PathBuf, +} + +pub struct HtmlDriver { + writer: W, + fg: Color, + bg: Color, +} + +impl Stroke { + fn as_css(&self) -> Option<&'static str> { + match self { + Stroke::None => None, + Stroke::Solid => Some("1pt solid"), + Stroke::Dashed => Some("1pt dashed"), + Stroke::Thick => Some("2pt solid"), + Stroke::Thin => Some("0.5pt solid"), + Stroke::Double => Some("double"), + } + } +} + +impl HtmlDriver { + pub fn new(config: &HtmlConfig) -> std::io::Result { + Ok(Self::for_writer(File::create(&config.file)?)) + } +} + +impl HtmlDriver +where + W: Write, +{ + pub fn for_writer(mut writer: W) -> Self { + let _ = put_header(&mut writer); + Self { + fg: Color::BLACK, + bg: Color::WHITE, + writer, + } + } + + fn render(&mut self, pivot_table: &PivotTable) -> std::io::Result<()> { + for layer_indexes in pivot_table.layers(true) { + let output = pivot_table.output(&layer_indexes, false); + write!(&mut self.writer, "")?; + + if let Some(title) = output.title { + let cell = title.get(Coord2::new(0, 0)); + self.put_cell( + DrawCell::new(cell.inner(), &title), + Rect2::new(0..1, 0..1), + false, + "caption", + None, + )?; + } + + if let Some(layers) = output.layers { + writeln!(&mut self.writer, "")?; + for cell in layers.cells() { + writeln!(&mut self.writer, "")?; + self.put_cell( + DrawCell::new(cell.inner(), &layers), + Rect2::new(0..output.body.n[Axis2::X], 0..1), + false, + "td", + None, + )?; + writeln!(&mut self.writer, "")?; + } + writeln!(&mut self.writer, "")?; + } + + writeln!(&mut self.writer, "")?; + for y in 0..output.body.n.y() { + writeln!(&mut self.writer, "")?; + for x in output.body.iter_x(y) { + let cell = output.body.get(Coord2::new(x, y)); + if cell.is_top_left() { + let is_header = x < output.body.h[Axis2::X] || y < output.body.h[Axis2::Y]; + let tag = if is_header { "th" } else { "td" }; + let alternate_row = y + .checked_sub(output.body.h[Axis2::Y]) + .is_some_and(|y| y % 2 == 1); + self.put_cell( + DrawCell::new(cell.inner(), &output.body), + cell.rect(), + alternate_row, + tag, + Some(&output.body), + )?; + } + } + writeln!(&mut self.writer, "")?; + } + writeln!(&mut self.writer, "")?; + + if output.caption.is_some() || output.footnotes.is_some() { + writeln!(&mut self.writer, "")?; + writeln!(&mut self.writer, "")?; + if let Some(caption) = output.caption { + self.put_cell( + DrawCell::new(caption.get(Coord2::new(0, 0)).inner(), &caption), + Rect2::new(0..output.body.n[Axis2::X], 0..1), + false, + "td", + None, + )?; + } + writeln!(&mut self.writer, "")?; + + if let Some(footnotes) = output.footnotes { + for cell in footnotes.cells() { + writeln!(&mut self.writer, "")?; + self.put_cell( + DrawCell::new(cell.inner(), &footnotes), + Rect2::new(0..output.body.n[Axis2::X], 0..1), + false, + "td", + None, + )?; + writeln!(&mut self.writer, "")?; + } + } + writeln!(&mut self.writer, "")?; + } + } + Ok(()) + } + + fn put_cell( + &mut self, + cell: DrawCell<'_>, + rect: Rect2, + alternate_row: bool, + tag: &str, + table: Option<&Table>, + ) -> std::io::Result<()> { + write!(&mut self.writer, "<{tag}")?; + let (body, suffixes) = cell.display().split_suffixes(); + + let mut style = String::new(); + let horz_align = match cell.horz_align(&body) { + HorzAlign::Right | HorzAlign::Decimal { .. } => Some("right"), + HorzAlign::Center => Some("center"), + HorzAlign::Left => None, + }; + if let Some(horz_align) = horz_align { + write!(&mut style, "text-align: {horz_align}; ").unwrap(); + } + + if cell.rotate { + write!(&mut style, "writing-mode: sideways-lr; ").unwrap(); + } + + let vert_align = match cell.style.cell_style.vert_align { + VertAlign::Top => None, + VertAlign::Middle => Some("middle"), + VertAlign::Bottom => Some("bottom"), + }; + if let Some(vert_align) = vert_align { + write!(&mut style, "vertical-align: {vert_align}; ").unwrap(); + } + let bg = cell.style.font_style.bg[alternate_row as usize]; + if bg != Color::WHITE { + write!(&mut style, "background: {}; ", bg.display_css()).unwrap(); + } + + let fg = cell.style.font_style.fg[alternate_row as usize]; + if fg != Color::BLACK { + write!(&mut style, "color: {}; ", fg.display_css()).unwrap(); + } + + if !cell.style.font_style.font.is_empty() { + write!( + &mut style, + r#"font-family: "{}"; "#, + Escape::new(&cell.style.font_style.font) + ) + .unwrap(); + } + + if cell.style.font_style.bold { + write!(&mut style, "font-weight: bold; ").unwrap(); + } + if cell.style.font_style.italic { + write!(&mut style, "font-style: italic; ").unwrap(); + } + if cell.style.font_style.underline { + write!(&mut style, "text-decoration: underline; ").unwrap(); + } + if cell.style.font_style.size != 0 { + write!(&mut style, "font-size: {}pt; ", cell.style.font_style.size).unwrap(); + } + + if let Some(table) = table { + Self::put_border(&mut style, table.get_rule(Axis2::Y, rect.top_left()), "top"); + Self::put_border( + &mut style, + table.get_rule(Axis2::X, rect.top_left()), + "left", + ); + if rect[Axis2::X].end == table.n[Axis2::X] { + Self::put_border( + &mut style, + table.get_rule( + Axis2::X, + Coord2::new(rect[Axis2::X].end, rect[Axis2::Y].start), + ), + "right", + ); + } + if rect[Axis2::Y].end == table.n[Axis2::Y] { + Self::put_border( + &mut style, + table.get_rule( + Axis2::Y, + Coord2::new(rect[Axis2::X].start, rect[Axis2::Y].end), + ), + "bottom", + ); + } + } + + if !style.is_empty() { + write!( + &mut self.writer, + " style='{}'", + Escape::new(style.trim_end_matches("; ")) + .with_apos("'") + .with_quote("\"") + )?; + } + + let col_span = rect[Axis2::X].len(); + if col_span > 1 { + write!(&mut self.writer, r#" colspan="{col_span}""#)?; + } + + let row_span = rect[Axis2::Y].len(); + if row_span > 1 { + write!(&mut self.writer, r#" rowspan="{row_span}""#)?; + } + + write!(&mut self.writer, ">")?; + + let mut text = SmallString::<[u8; 64]>::new(); + write!(&mut text, "{body}").unwrap(); + write!( + &mut self.writer, + "{}", + Escape::new(&text).with_newline("
") + )?; + + if suffixes.has_subscripts() { + write!(&mut self.writer, "")?; + for (index, subscript) in suffixes.subscripts().enumerate() { + if index > 0 { + write!(&mut self.writer, ",")?; + } + write!( + &mut self.writer, + "{}", + Escape::new(subscript) + .with_space(" ") + .with_newline("
") + )?; + } + write!(&mut self.writer, "
")?; + } + + if suffixes.has_footnotes() { + write!(&mut self.writer, "")?; + for (index, footnote) in suffixes.footnotes().enumerate() { + if index > 0 { + write!(&mut self.writer, ",")?; + } + let mut marker = SmallString::<[u8; 8]>::new(); + write!(&mut marker, "{footnote}").unwrap(); + write!( + &mut self.writer, + "{}", + Escape::new(&marker) + .with_space(" ") + .with_newline("
") + )?; + } + write!(&mut self.writer, "
")?; + } + + writeln!(&mut self.writer, "") + } + + fn put_border(dst: &mut String, style: BorderStyle, border_name: &str) { + if let Some(css_style) = style.stroke.as_css() { + write!(dst, "border-{border_name}: {css_style}").unwrap(); + if style.color != Color::BLACK { + write!(dst, " {}", style.color.display_css()).unwrap(); + } + write!(dst, "; ").unwrap(); + } + } +} + +fn put_header(mut writer: W) -> std::io::Result<()> +where + W: Write, +{ + write!( + &mut writer, + r#" + + +PSPP Output + + +{} +"#, + Escape::new(env!("CARGO_PKG_VERSION")), + HEADER_CSS, + )?; + Ok(()) +} + +const HEADER_CSS: &str = r#" + + +"#; + +impl Driver for HtmlDriver +where + W: Write, +{ + fn name(&self) -> Cow<'static, str> { + Cow::from("html") + } + + fn write(&mut self, item: &Arc) { + match &item.details { + Details::Chart => todo!(), + Details::Image => todo!(), + Details::Group(_) => todo!(), + Details::Message(_diagnostic) => todo!(), + Details::PageBreak => (), + Details::Table(pivot_table) => { + self.render(pivot_table).unwrap(); // XXX + } + Details::Text(_text) => todo!(), + } + } +} + +struct Escape<'a> { + string: &'a str, + space: &'static str, + newline: &'static str, + quote: &'static str, + apos: &'static str, +} + +impl<'a> Escape<'a> { + fn new(string: &'a str) -> Self { + Self { + string, + space: " ", + newline: "\n", + quote: """, + apos: "'", + } + } + fn with_space(self, space: &'static str) -> Self { + Self { space, ..self } + } + fn with_newline(self, newline: &'static str) -> Self { + Self { newline, ..self } + } + fn with_quote(self, quote: &'static str) -> Self { + Self { quote, ..self } + } + fn with_apos(self, apos: &'static str) -> Self { + Self { apos, ..self } + } +} + +impl Display for Escape<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for c in self.string.chars() { + match c { + '\n' => f.write_str(self.newline)?, + ' ' => f.write_str(self.space)?, + '&' => f.write_str("&")?, + '<' => f.write_str("<")?, + '>' => f.write_str(">")?, + '"' => f.write_str(self.quote)?, + '\'' => f.write_str(self.apos)?, + _ => f.write_char(c)?, + } + } + Ok(()) + } +} diff --git a/rust/pspp/src/output/drivers/json.rs b/rust/pspp/src/output/drivers/json.rs new file mode 100644 index 0000000000..8288bdde37 --- /dev/null +++ b/rust/pspp/src/output/drivers/json.rs @@ -0,0 +1,58 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use std::{ + borrow::Cow, + fs::File, + io::{BufWriter, Write}, + path::PathBuf, + sync::Arc, +}; + +use serde::{Deserialize, Serialize}; + +use super::{Driver, Item}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct JsonConfig { + file: PathBuf, +} + +pub struct JsonDriver { + file: BufWriter, +} + +impl JsonDriver { + pub fn new(config: &JsonConfig) -> std::io::Result { + Ok(Self { + file: BufWriter::new(File::create(&config.file)?), + }) + } +} + +impl Driver for JsonDriver { + fn name(&self) -> Cow<'static, str> { + Cow::from("json") + } + + fn write(&mut self, item: &Arc) { + serde_json::to_writer_pretty(&mut self.file, item).unwrap(); // XXX handle errors + } + + fn flush(&mut self) { + let _ = self.file.flush(); + } +} diff --git a/rust/pspp/src/output/drivers/spv.rs b/rust/pspp/src/output/drivers/spv.rs new file mode 100644 index 0000000000..58c40f6282 --- /dev/null +++ b/rust/pspp/src/output/drivers/spv.rs @@ -0,0 +1,1373 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use core::f64; +use std::{ + borrow::Cow, + fs::File, + io::{Cursor, Seek, Write}, + iter::{repeat, repeat_n}, + path::PathBuf, + sync::Arc, +}; + +use binrw::{BinWrite, Endian}; +use chrono::Utc; +use enum_map::EnumMap; +use quick_xml::{ + ElementWriter, + events::{BytesText, attributes::Attribute}, + writer::Writer as XmlWriter, +}; +use serde::{Deserialize, Serialize}; +use zip::{ZipWriter, result::ZipResult, write::SimpleFileOptions}; + +use crate::{ + format::{Format, Type}, + output::{ + Details, Item, Text, + drivers::Driver, + page::{ChartSize, Heading, PageSetup}, + pivot::{ + Area, AreaStyle, Axis2, Axis3, Border, BorderStyle, BoxBorder, Category, CellStyle, + Color, Dimension, FontStyle, Footnote, FootnoteMarkerPosition, FootnoteMarkerType, + Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Leaf, PivotTable, + RowColBorder, Stroke, Value, ValueInner, ValueStyle, VertAlign, + }, + }, + settings::Show, + util::ToSmallString, +}; + +fn light_table_name(table_id: u64) -> String { + format!("{table_id:011}_lightTableData.bin") +} + +fn output_viewer_name(heading_id: u64, is_heading: bool) -> String { + format!( + "outputViewer{heading_id:010}{}.xml", + if is_heading { "_heading" } else { "" } + ) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SpvConfig { + /// Output file name. + pub file: PathBuf, + + /// Page setup. + pub page_setup: Option, +} + +pub struct SpvDriver +where + W: Write + Seek, +{ + writer: ZipWriter, + needs_page_break: bool, + next_table_id: u64, + next_heading_id: u64, + page_setup: Option, +} + +impl SpvDriver { + pub fn new(config: &SpvConfig) -> std::io::Result { + let mut driver = Self::for_writer(File::create(&config.file)?); + if let Some(page_setup) = &config.page_setup { + driver = driver.with_page_setup(page_setup.clone()); + } + Ok(driver) + } +} + +impl SpvDriver +where + W: Write + Seek, +{ + pub fn for_writer(writer: W) -> Self { + let mut writer = ZipWriter::new(writer); + writer + .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default()) + .unwrap(); + writer.write_all("allowPivoting=true".as_bytes()).unwrap(); + Self { + writer, + needs_page_break: false, + next_table_id: 1, + next_heading_id: 1, + page_setup: None, + } + } + + pub fn with_page_setup(self, page_setup: PageSetup) -> Self { + Self { + page_setup: Some(page_setup), + ..self + } + } + + pub fn close(mut self) -> ZipResult { + self.writer + .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default())?; + write!(&mut self.writer, "allowPivoting=true")?; + self.writer.finish() + } + + fn page_break_before(&mut self) -> bool { + let page_break_before = self.needs_page_break; + self.needs_page_break = false; + page_break_before + } + + fn write_table( + &mut self, + item: &Item, + pivot_table: &PivotTable, + structure: &mut XmlWriter, + ) where + X: Write, + { + let table_id = self.next_table_id; + self.next_table_id += 1; + + let mut content = Vec::new(); + let mut cursor = Cursor::new(&mut content); + pivot_table.write_le(&mut cursor).unwrap(); + + let table_name = light_table_name(table_id); + self.writer + .start_file(&table_name, SimpleFileOptions::default()) + .unwrap(); // XXX + self.writer.write_all(&content).unwrap(); // XXX + + self.container(structure, item, "vtb:table", |element| { + element + .with_attribute(("tableId", Cow::from(table_id.to_string()))) + .with_attribute(( + "subType", + Cow::from(pivot_table.subtype().display(pivot_table).to_string()), + )) + .write_inner_content(|w| { + w.create_element("vtb:tableStructure") + .write_inner_content(|w| { + w.create_element("vtb:dataPath") + .write_text_content(BytesText::new(&table_name))?; + Ok(()) + })?; + Ok(()) + }) + .unwrap(); + }); + } + + fn write_text(&mut self, item: &Item, text: &Text, structure: &mut XmlWriter) + where + X: Write, + { + self.container(structure, item, "vtx:text", |w| { + w.with_attribute(("type", text.type_.as_xml_str())) + .write_text_content(BytesText::new(&text.content.display(()).to_string())) + .unwrap(); + }); + } + + fn write_item(&mut self, item: &Item, structure: &mut XmlWriter) + where + X: Write, + { + match &item.details { + Details::Chart => todo!(), + Details::Image => todo!(), + Details::Group(children) => { + let mut attributes = Vec::::new(); + if let Some(command_name) = &item.command_name { + attributes.push(("commandName", command_name.as_str()).into()); + } + if !item.show { + attributes.push(("visibility", "collapsed").into()); + } + structure + .create_element("heading") + .with_attributes(attributes) + .write_inner_content(|w| { + w.create_element("label") + .write_text_content(BytesText::new(&item.label()))?; + for child in children { + self.write_item(child, w); + } + Ok(()) + }) + .unwrap(); + } + Details::Message(diagnostic) => { + self.write_text(item, &Text::from(diagnostic.as_ref()), structure) + } + Details::PageBreak => { + self.needs_page_break = true; + } + Details::Table(pivot_table) => self.write_table(item, pivot_table, structure), + Details::Text(text) => self.write_text(item, text, structure), + } + } + + fn container( + &mut self, + writer: &mut XmlWriter, + item: &Item, + inner_elem: &str, + closure: F, + ) where + X: Write, + F: FnOnce(ElementWriter), + { + writer + .create_element("container") + .with_attributes( + self.page_break_before() + .then_some(("page-break-before", "always")), + ) + .with_attribute(("visibility", if item.show { "visible" } else { "hidden" })) + .write_inner_content(|w| { + let mut element = w + .create_element("label") + .write_text_content(BytesText::new(&item.label())) + .unwrap() + .create_element(inner_elem); + if let Some(command_name) = &item.command_name { + element = element.with_attribute(("commandName", command_name.as_str())); + }; + closure(element); + Ok(()) + }) + .unwrap(); + } +} + +impl BinWrite for PivotTable { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + _args: (), + ) -> binrw::BinResult<()> { + // Header. + ( + 1u8, + 0u8, + 3u32, // version + SpvBool(true), // x0 + SpvBool(false), // x1 + SpvBool(self.rotate_inner_column_labels), + SpvBool(self.rotate_outer_row_labels), + SpvBool(true), // x2 + 0x15u32, // x3 + *self.look.heading_widths[HeadingRegion::Columns].start() as i32, + *self.look.heading_widths[HeadingRegion::Columns].end() as i32, + *self.look.heading_widths[HeadingRegion::Rows].start() as i32, + *self.look.heading_widths[HeadingRegion::Rows].end() as i32, + 0u64, + ) + .write_le(writer)?; + + // Titles. + ( + self.title(), + self.subtype(), + Optional(Some(self.title())), + Optional(self.corner_text.as_ref()), + Optional(self.caption.as_ref()), + ) + .write_le(writer)?; + + // Footnotes. + self.footnotes.write_le(writer)?; + + // Areas. + static SPV_AREAS: [Area; 8] = [ + Area::Title, + Area::Caption, + Area::Footer, + Area::Corner, + Area::Labels(Axis2::X), + Area::Labels(Axis2::Y), + Area::Data, + Area::Layers, + ]; + for (index, area) in SPV_AREAS.into_iter().enumerate() { + self.look.areas[area].write_le_args(writer, index)?; + } + + // Borders. + static SPV_BORDERS: [Border; 19] = [ + Border::Title, + Border::OuterFrame(BoxBorder::Left), + Border::OuterFrame(BoxBorder::Top), + Border::OuterFrame(BoxBorder::Right), + Border::OuterFrame(BoxBorder::Bottom), + Border::InnerFrame(BoxBorder::Left), + Border::InnerFrame(BoxBorder::Top), + Border::InnerFrame(BoxBorder::Right), + Border::InnerFrame(BoxBorder::Bottom), + Border::DataLeft, + Border::DataTop, + Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)), + Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::Y)), + Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)), + Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::Y)), + Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)), + Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::Y)), + Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)), + Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::Y)), + ]; + let borders_start = Count::new(writer)?; + (1, SPV_BORDERS.len() as u32).write_be(writer)?; + for (index, border) in SPV_BORDERS.into_iter().enumerate() { + self.look.borders[border].write_be_args(writer, index)?; + } + (SpvBool(self.show_grid_lines), 0u8, 0u16).write_le(writer)?; + borders_start.finish_le32(writer)?; + + // Print Settings. + Counted::new(( + 1u32, + SpvBool(self.look.print_all_layers), + SpvBool(self.look.paginate_layers), + SpvBool(self.look.shrink_to_fit[Axis2::X]), + SpvBool(self.look.shrink_to_fit[Axis2::Y]), + SpvBool(self.look.top_continuation), + SpvBool(self.look.bottom_continuation), + self.look.n_orphan_lines as u32, + SpvString(self.look.continuation.as_ref().map_or("", |s| s.as_str())), + )) + .with_endian(Endian::Little) + .write_be(writer)?; + + // Table Settings. + Counted::new(( + 1u32, + 4u32, + self.spv_layer() as u32, + SpvBool(self.look.hide_empty), + SpvBool(self.look.row_label_position == LabelPosition::Corner), + SpvBool(self.look.footnote_marker_type == FootnoteMarkerType::Alphabetic), + SpvBool(self.look.footnote_marker_position == FootnoteMarkerPosition::Superscript), + 0u8, + Counted::new(( + 0u32, // n-row-breaks + 0u32, // n-column-breaks + 0u32, // n-row-keeps + 0u32, // n-column-keeps + 0u32, // n-row-point-keeps + 0u32, // n-column-point-keeps + )), + SpvString::optional(&self.notes), + SpvString::optional(&self.look.name), + Zeros(82), + )) + .with_endian(Endian::Little) + .write_be(writer)?; + + fn y0(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { + ( + pivot_table.settings.epoch.0 as u32, + u8::from(pivot_table.settings.decimal), + b',', + ) + } + + fn custom_currency(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { + ( + 5, + EnumMap::from_fn(|cc| { + SpvString(pivot_table.settings.number_style(Type::CC(cc)).to_string()) + }) + .into_array(), + ) + } + + fn x1(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { + ( + 0u8, // x14 + if pivot_table.show_title { 1u8 } else { 10u8 }, + 0u8, // x16 + 0u8, // lang + Show::as_spv(&pivot_table.show_variables), + Show::as_spv(&pivot_table.show_values), + -1i32, // x18 + -1i32, // x19 + Zeros(17), + SpvBool(false), // x20 + SpvBool(pivot_table.show_caption), + ) + } + + fn x2() -> impl for<'a> BinWrite = ()> { + Counted::new(( + 0u32, // n-row-heights + 0u32, // n-style-maps + 0u32, // n-styles, + 0u32, + )) + } + + fn y1(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> + use<'_> { + ( + SpvString::optional(&pivot_table.command_c), + SpvString::optional(&pivot_table.command_local), + SpvString::optional(&pivot_table.language), + SpvString("UTF-8"), + SpvString::optional(&pivot_table.locale), + SpvBool(false), // x10 + SpvBool(pivot_table.settings.leading_zero), + SpvBool(true), // x12 + SpvBool(true), // x13 + y0(pivot_table), + ) + } + + fn y2(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { + (custom_currency(pivot_table), b'.', SpvBool(false)) + } + + fn x3(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> + use<'_> { + Counted::new(( + 1u8, + 0u8, + 4u8, // x21 + 0u8, + 0u8, + 0u8, + y1(pivot_table), + pivot_table.small, + 1u8, + SpvString::optional(&pivot_table.dataset), + SpvString::optional(&pivot_table.datafile), + 0u32, + pivot_table + .date + .map_or(0i64, |date| date.and_utc().timestamp()), + y2(pivot_table), + )) + } + + // Formats. + ( + 0u32, + SpvString("en_US.ISO_8859-1:1987"), + 0u32, // XXX current_layer + SpvBool(false), // x7 + SpvBool(false), // x8 + SpvBool(false), // x9 + y0(self), + custom_currency(self), + Counted::new((Counted::new((x1(self), x2())), x3(self))), + ) + .write_le(writer)?; + + // Dimensions. + (self.dimensions.len() as u32).write_le(writer)?; + + let x2 = repeat_n(2, self.axes[Axis3::Z].dimensions.len()) + .chain(repeat_n(0, self.axes[Axis3::Y].dimensions.len())) + .chain(repeat(1)); + for ((index, dimension), x2) in self.dimensions.iter().enumerate().zip(x2) { + dimension.write_options(writer, endian, (index, x2))?; + } + + // Axes. + for axis in [Axis3::Z, Axis3::Y, Axis3::X] { + (self.axes[axis].dimensions.len() as u32).write_le(writer)?; + } + for axis in [Axis3::Z, Axis3::Y, Axis3::X] { + for index in self.axes[axis].dimensions.iter().copied() { + (index as u32).write_le(writer)?; + } + } + + // Cells. + (self.cells.len() as u32).write_le(writer)?; + for (index, value) in &self.cells { + (*index as u64, value).write_le(writer)?; + } + + Ok(()) + } +} + +impl PivotTable { + fn spv_layer(&self) -> usize { + let mut layer = 0; + for (dimension, layer_value) in self + .axis_dimensions(Axis3::Z) + .zip(self.current_layer.iter().copied()) + .rev() + { + layer = layer * dimension.len() + layer_value; + } + layer + } +} + +impl Driver for SpvDriver +where + W: Write + Seek, +{ + fn name(&self) -> Cow<'static, str> { + Cow::from("spv") + } + + fn write(&mut self, item: &Arc) { + if item.details.is_page_break() { + self.needs_page_break = true; + return; + } + + let mut headings = XmlWriter::new(Cursor::new(Vec::new())); + let element = headings + .create_element("heading") + .with_attribute(( + "creation-date-time", + Cow::from(Utc::now().format("%x %x").to_string()), + )) + .with_attribute(( + "creator", + Cow::from(format!( + "{} {}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION") + )), + )) + .with_attribute(("creator-version", "21")) + .with_attribute(("xmlns", "http://xml.spss.com/spss/viewer/viewer-tree")) + .with_attribute(( + "xmlns:vps", + "http://xml.spss.com/spss/viewer/viewer-pagesetup", + )) + .with_attribute(("xmlns:vtx", "http://xml.spss.com/spss/viewer/viewer-text")) + .with_attribute(("xmlns:vtb", "http://xml.spss.com/spss/viewer/viewer-table")); + element + .write_inner_content(|w| { + w.create_element("label") + .write_text_content(BytesText::new("Output"))?; + if let Some(page_setup) = self.page_setup.take() { + write_page_setup(&page_setup, w)?; + } + self.write_item(item, w); + Ok(()) + }) + .unwrap(); + + let headings = headings.into_inner().into_inner(); + let heading_id = self.next_heading_id; + self.next_heading_id += 1; + self.writer + .start_file( + output_viewer_name(heading_id, item.details.as_group().is_some()), + SimpleFileOptions::default(), + ) + .unwrap(); // XXX + self.writer.write_all(&headings).unwrap(); // XXX + } +} + +fn write_page_setup(page_setup: &PageSetup, writer: &mut XmlWriter) -> std::io::Result<()> +where + X: Write, +{ + fn inches<'a>(x: f64) -> Cow<'a, str> { + Cow::from(format!("{x:.2}in")) + } + + writer + .create_element("vps:pageSetup") + .with_attribute(( + "initial-page-number", + Cow::from(format!("{}", page_setup.initial_page_number)), + )) + .with_attribute(( + "chart-size", + match page_setup.chart_size { + ChartSize::AsIs => "as-is", + ChartSize::FullHeight => "full-height", + ChartSize::HalfHeight => "half-height", + ChartSize::QuarterHeight => "quarter-height", + }, + )) + .with_attribute(("margin-left", inches(page_setup.margins[Axis2::X][0]))) + .with_attribute(("margin-right", inches(page_setup.margins[Axis2::X][1]))) + .with_attribute(("margin-top", inches(page_setup.margins[Axis2::Y][0]))) + .with_attribute(("margin-bottom", inches(page_setup.margins[Axis2::Y][1]))) + .with_attribute(("paper-height", inches(page_setup.paper[Axis2::Y]))) + .with_attribute(("paper-width", inches(page_setup.paper[Axis2::X]))) + .with_attribute(( + "reference-orientation", + match page_setup.orientation { + crate::output::page::Orientation::Portrait => "portrait", + crate::output::page::Orientation::Landscape => "landscape", + }, + )) + .with_attribute(( + "space-after", + Cow::from(format!("{:.1}pt", page_setup.object_spacing * 72.0)), + )) + .write_inner_content(|w| { + write_page_heading(&page_setup.headings[0], "vps:pageHeader", w)?; + write_page_heading(&page_setup.headings[1], "vps:pageFooter", w)?; + Ok(()) + })?; + Ok(()) +} + +fn write_page_heading( + heading: &Heading, + name: &str, + writer: &mut XmlWriter, +) -> std::io::Result<()> +where + X: Write, +{ + let element = writer.create_element(name); + if !heading.0.is_empty() { + element.write_inner_content(|w| { + w.create_element("vps:pageParagraph") + .write_inner_content(|w| { + for paragraph in &heading.0 { + w.create_element("vtx:text") + .with_attribute(("text", "title")) + .write_text_content(BytesText::new(¶graph.markup))?; + } + Ok(()) + })?; + Ok(()) + })?; + } + Ok(()) +} + +fn maybe_with_attribute<'a, 'b, W, I>( + element: ElementWriter<'a, W>, + attr: Option, +) -> ElementWriter<'a, W> +where + I: Into>, +{ + if let Some(attr) = attr { + element.with_attribute(attr) + } else { + element + } +} + +impl BinWrite for Dimension { + type Args<'a> = (usize, u8); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + (index, x2): (usize, u8), + ) -> binrw::BinResult<()> { + ( + &self.root.name, + 0u8, // x1 + x2, + 2u32, // x3 + SpvBool(!self.root.show_label), + SpvBool(self.hide_all_labels), + SpvBool(true), + index as u32, + self.root.children.len() as u32, + ) + .write_options(writer, endian, ())?; + + let mut data_indexes = self.presentation_order.iter().copied(); + for child in &self.root.children { + child.write_le(writer, &mut data_indexes)?; + } + Ok(()) + } +} + +impl Category { + fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> + where + W: Write + Seek, + D: Iterator, + { + match self { + Category::Group(group) => group.write_le(writer, data_indexes), + Category::Leaf(leaf) => leaf.write_le(writer, data_indexes), + } + } +} + +impl Leaf { + fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> + where + W: Write + Seek, + D: Iterator, + { + ( + self.name(), + 0u8, + 0u8, + 0u8, + 2u32, + data_indexes.next().unwrap() as u32, + 0u32, + ) + .write_le(writer) + } +} + +impl Group { + fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> + where + W: Write + Seek, + D: Iterator, + { + ( + self.name(), + 0u8, + 0u8, + 1u8, + 0u32, // x23 + -1i32, + ) + .write_le(writer)?; + + for child in &self.children { + child.write_le(writer, data_indexes)?; + } + Ok(()) + } +} + +impl BinWrite for Footnote { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + ( + &self.content, + Optional(self.marker.as_ref()), + if self.show { 1i32 } else { -1 }, + ) + .write_options(writer, endian, args) + } +} + +impl BinWrite for Footnotes { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + (self.0.len() as u32).write_options(writer, endian, args)?; + for footnote in &self.0 { + footnote.write_options(writer, endian, args)?; + } + Ok(()) + } +} + +impl BinWrite for AreaStyle { + type Args<'a> = usize; + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + index: usize, + ) -> binrw::BinResult<()> { + let typeface = if self.font_style.font.is_empty() { + "SansSerif" + } else { + self.font_style.font.as_str() + }; + ( + (index + 1) as u8, + 0x31u8, + SpvString(typeface), + self.font_style.size as f32 * 1.33, + self.font_style.bold as u32 + 2 * self.font_style.italic as u32, + SpvBool(self.font_style.underline), + self.cell_style + .horz_align + .map_or(64173, |horz_align| horz_align.as_spv(61453)), + self.cell_style.vert_align.as_spv(), + self.font_style.fg[0], + self.font_style.bg[0], + ) + .write_options(writer, endian, ())?; + + if self.font_style.fg[0] != self.font_style.fg[1] + || self.font_style.bg[0] != self.font_style.bg[1] + { + (SpvBool(true), self.font_style.fg[1], self.font_style.bg[1]).write_options( + writer, + endian, + (), + )?; + } else { + (SpvBool(false), SpvString(""), SpvString("")).write_options(writer, endian, ())?; + } + + ( + self.cell_style.margins[Axis2::X][0], + self.cell_style.margins[Axis2::X][1], + self.cell_style.margins[Axis2::Y][0], + self.cell_style.margins[Axis2::Y][1], + ) + .write_options(writer, endian, ()) + } +} + +impl Stroke { + fn as_spv(&self) -> u32 { + match self { + Stroke::None => 0, + Stroke::Solid => 1, + Stroke::Dashed => 2, + Stroke::Thick => 3, + Stroke::Thin => 4, + Stroke::Double => 5, + } + } +} + +impl Color { + fn as_spv(&self) -> u32 { + ((self.alpha as u32) << 24) + | ((self.r as u32) << 16) + | ((self.g as u32) << 8) + | (self.b as u32) + } +} + +impl BinWrite for BorderStyle { + type Args<'a> = usize; + + fn write_options( + &self, + writer: &mut W, + _endian: Endian, + index: usize, + ) -> binrw::BinResult<()> { + (index as u32, self.stroke.as_spv(), self.color.as_spv()).write_be(writer) + } +} + +struct SpvBool(bool); +impl BinWrite for SpvBool { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + (self.0 as u8).write_options(writer, endian, args) + } +} + +struct SpvString(T); +impl<'a> SpvString<&'a str> { + fn optional(s: &'a Option) -> Self { + Self(s.as_ref().map_or("", |s| s.as_str())) + } +} +impl BinWrite for SpvString +where + T: AsRef, +{ + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + let s = self.0.as_ref(); + let length = s.len() as u32; + (length, s.as_bytes()).write_options(writer, endian, args) + } +} + +impl Show { + fn as_spv(this: &Option) -> u8 { + match this { + None => 0, + Some(Show::Value) => 1, + Some(Show::Label) => 2, + Some(Show::Both) => 3, + } + } +} + +struct Count(u64); + +impl Count { + fn new(writer: &mut W) -> binrw::BinResult + where + W: Write + Seek, + { + 0u32.write_le(writer)?; + Ok(Self(writer.stream_position()?)) + } + + fn finish(self, writer: &mut W, endian: Endian) -> binrw::BinResult<()> + where + W: Write + Seek, + { + let saved_position = writer.stream_position()?; + let n_bytes = saved_position - self.0; + writer.seek(std::io::SeekFrom::Start(self.0 - 4))?; + (n_bytes as u32).write_options(writer, endian, ())?; + writer.seek(std::io::SeekFrom::Start(saved_position))?; + Ok(()) + } + + fn finish_le32(self, writer: &mut W) -> binrw::BinResult<()> + where + W: Write + Seek, + { + self.finish(writer, Endian::Little) + } + + fn finish_be32(self, writer: &mut W) -> binrw::BinResult<()> + where + W: Write + Seek, + { + self.finish(writer, Endian::Big) + } +} + +struct Counted { + inner: T, + endian: Option, +} + +impl Counted { + fn new(inner: T) -> Self { + Self { + inner, + endian: None, + } + } + fn with_endian(self, endian: Endian) -> Self { + Self { + inner: self.inner, + endian: Some(endian), + } + } +} + +impl BinWrite for Counted +where + T: BinWrite, + for<'a> T: BinWrite = ()>, +{ + type Args<'a> = T::Args<'a>; + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + let start = Count::new(writer)?; + self.inner.write_options(writer, endian, args)?; + start.finish(writer, self.endian.unwrap_or(endian)) + } +} + +pub struct Zeros(pub usize); + +impl BinWrite for Zeros { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + _endian: Endian, + _args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + for _ in 0..self.0 { + writer.write_all(&[0u8])?; + } + Ok(()) + } +} + +#[derive(Default)] +struct StylePair<'a> { + font_style: Option<&'a FontStyle>, + cell_style: Option<&'a CellStyle>, +} + +impl BinWrite for Color { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + SpvString(&self.without_alpha().display_css().to_small_string::<16>()) + .write_options(writer, endian, args) + } +} + +impl BinWrite for FontStyle { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + let typeface = if self.font.is_empty() { + "SansSerif" + } else { + self.font.as_str() + }; + ( + SpvBool(self.bold), + SpvBool(self.italic), + SpvBool(self.underline), + SpvBool(true), + self.fg[0], + self.bg[0], + SpvString(typeface), + (self.size as f64 * 1.33).ceil() as u8, + ) + .write_options(writer, endian, args) + } +} + +impl HorzAlign { + fn as_spv(&self, decimal: u32) -> u32 { + match self { + HorzAlign::Right => 4, + HorzAlign::Left => 2, + HorzAlign::Center => 0, + HorzAlign::Decimal { .. } => decimal, + } + } + + fn decimal_offset(&self) -> Option { + match *self { + HorzAlign::Decimal { offset, .. } => Some(offset), + _ => None, + } + } +} + +impl VertAlign { + fn as_spv(&self) -> u32 { + match self { + VertAlign::Top => 1, + VertAlign::Middle => 0, + VertAlign::Bottom => 3, + } + } +} + +impl BinWrite for CellStyle { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + ( + self.horz_align + .map_or(0xffffffad, |horz_align| horz_align.as_spv(6)), + self.vert_align.as_spv(), + self.horz_align + .map(|horz_align| horz_align.decimal_offset()) + .unwrap_or_default(), + u16::try_from(self.margins[Axis2::X][0]).unwrap_or_default(), + u16::try_from(self.margins[Axis2::X][1]).unwrap_or_default(), + u16::try_from(self.margins[Axis2::Y][0]).unwrap_or_default(), + u16::try_from(self.margins[Axis2::Y][1]).unwrap_or_default(), + ) + .write_options(writer, endian, args) + } +} + +impl<'a> BinWrite for StylePair<'a> { + type Args<'b> = (); + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + ( + Optional(self.font_style.as_ref()), + Optional(self.cell_style.as_ref()), + ) + .write_options(writer, endian, args) + } +} + +struct Optional(Option); + +impl BinWrite for Optional +where + T: BinWrite, +{ + type Args<'a> = T::Args<'a>; + + fn write_options( + &self, + writer: &mut W, + endian: Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + match &self.0 { + Some(value) => { + 0x31u8.write_le(writer)?; + value.write_options(writer, endian, args) + } + None => 0x58u8.write_le(writer), + } + } +} + +struct ValueMod<'a> { + style: &'a Option>, + template: Option<&'a str>, +} + +impl<'a> ValueMod<'a> { + fn new(value: &'a Value) -> Self { + Self { + style: &value.styling, + template: None, + } + } +} + +impl<'a> Default for ValueMod<'a> { + fn default() -> Self { + Self { + style: &None, + template: None, + } + } +} + +impl<'a> BinWrite for ValueMod<'a> { + type Args<'b> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + if self.style.as_ref().is_some_and(|style| !style.is_empty()) || self.template.is_some() { + 0x31u8.write_options(writer, endian, args)?; + let default_style = Default::default(); + let style = self.style.as_ref().unwrap_or(&default_style); + + (style.footnotes.len() as u32).write_options(writer, endian, args)?; + for footnote in &style.footnotes { + (footnote.index() as u16).write_options(writer, endian, args)?; + } + + (style.subscripts.len() as u32).write_options(writer, endian, args)?; + for subscript in &style.subscripts { + SpvString(subscript.as_str()).write_options(writer, endian, args)?; + } + let v3_start = Count::new(writer)?; + let template_string_start = Count::new(writer)?; + if let Some(template) = self.template { + Count::new(writer)?.finish_le32(writer)?; + (0x31u8, SpvString(template)).write_options(writer, endian, args)?; + } + template_string_start.finish_le32(writer)?; + style + .style + .as_ref() + .map_or_else(StylePair::default, |area_style| StylePair { + font_style: Some(&area_style.font_style), + cell_style: Some(&area_style.cell_style), + }) + .write_options(writer, endian, args)?; + v3_start.finish_le32(writer) + } else { + 0x58u8.write_options(writer, endian, args) + } + } +} + +struct SpvFormat { + format: Format, + honor_small: bool, +} + +impl BinWrite for SpvFormat { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + let type_ = if self.format.type_() == Type::F && self.honor_small { + 40 + } else { + self.format.type_().into() + }; + (((type_ as u32) << 16) | ((self.format.w() as u32) << 8) | (self.format.d() as u32)) + .write_options(writer, endian, args) + } +} + +impl BinWrite for Value { + type Args<'a> = (); + + fn write_options( + &self, + writer: &mut W, + endian: binrw::Endian, + args: Self::Args<'_>, + ) -> binrw::BinResult<()> { + match &self.inner { + ValueInner::Number(number) => { + let format = SpvFormat { + format: number.format, + honor_small: number.honor_small, + }; + if number.variable.is_some() || number.value_label.is_some() { + ( + 2u8, + ValueMod::new(self), + format, + number.value.unwrap_or(f64::MIN), + SpvString::optional(&number.variable), + SpvString::optional(&number.value_label), + Show::as_spv(&number.show), + ) + .write_options(writer, endian, args)?; + } else { + ( + 1u8, + ValueMod::new(self), + format, + number.value.unwrap_or(f64::MIN), + ) + .write_options(writer, endian, args)?; + } + } + ValueInner::String(string) => { + ( + 4u8, + ValueMod::new(self), + SpvFormat { + format: if string.hex { + Format::new(Type::AHex, (string.s.len() * 2) as u16, 0).unwrap() + } else { + Format::new(Type::A, (string.s.len()) as u16, 0).unwrap() + }, + honor_small: false, + }, + SpvString::optional(&string.value_label), + SpvString::optional(&string.var_name), + Show::as_spv(&string.show), + SpvString(&string.s), + ) + .write_options(writer, endian, args)?; + } + ValueInner::Variable(variable) => { + ( + 5u8, + ValueMod::new(self), + SpvString(&variable.var_name), + SpvString::optional(&variable.variable_label), + Show::as_spv(&variable.show), + ) + .write_options(writer, endian, args)?; + } + ValueInner::Text(text) => { + ( + 3u8, + SpvString(&text.localized), + ValueMod::new(self), + SpvString(text.id()), + SpvString(text.c()), + SpvBool(true), + ) + .write_options(writer, endian, args)?; + } + ValueInner::Template(template) => { + ( + 0u8, + ValueMod::new(self), + SpvString(&template.localized), + template.args.len() as u32, + ) + .write_options(writer, endian, args)?; + for arg in &template.args { + if arg.len() > 1 { + (arg.len() as u32, 0u32).write_options(writer, endian, args)?; + for (index, value) in arg.iter().enumerate() { + if index > 0 { + 0u32.write_le(writer)?; + } + value.write_options(writer, endian, args)?; + } + } else { + (0u32, arg).write_options(writer, endian, args)?; + } + } + } + ValueInner::Empty => { + ( + 3u8, + SpvString(""), + ValueMod::default(), + SpvString(""), + SpvString(""), + SpvBool(true), + ) + .write_options(writer, endian, args)?; + } + } + Ok(()) + } +} diff --git a/rust/pspp/src/output/drivers/text.rs b/rust/pspp/src/output/drivers/text.rs new file mode 100644 index 0000000000..8ad1c477e4 --- /dev/null +++ b/rust/pspp/src/output/drivers/text.rs @@ -0,0 +1,709 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use std::{ + borrow::Cow, + fmt::{Display, Error as FmtError, Result as FmtResult, Write as FmtWrite}, + fs::File, + io::{BufWriter, Write as IoWrite}, + ops::{Index, Range}, + path::PathBuf, + sync::{Arc, LazyLock}, +}; + +use enum_map::{Enum, EnumMap, enum_map}; +use serde::{Deserialize, Serialize}; +use unicode_linebreak::{BreakOpportunity, linebreaks}; +use unicode_width::UnicodeWidthStr; + +use crate::output::{render::Extreme, table::DrawCell}; + +use crate::output::{ + Details, Item, + drivers::Driver, + pivot::{Axis2, BorderStyle, Coord2, HorzAlign, PivotTable, Rect2, Stroke}, + render::{Device, Pager, Params}, + table::Content, +}; + +mod text_line; +use text_line::{Emphasis, TextLine, clip_text}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Boxes { + Ascii, + #[default] + Unicode, +} + +impl Boxes { + fn box_chars(&self) -> &'static BoxChars { + match self { + Boxes::Ascii => &ASCII_BOX, + Boxes::Unicode => &UNICODE_BOX, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TextConfig { + /// Output file name. + file: Option, + + /// Renderer config. + #[serde(flatten)] + options: TextRendererOptions, +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct TextRendererOptions { + /// Enable bold and underline in output? + pub emphasis: bool, + + /// Page width. + pub width: Option, + + /// ASCII or Unicode + pub boxes: Boxes, +} + +pub struct TextRenderer { + /// Enable bold and underline in output? + emphasis: bool, + + /// Page width. + width: usize, + + /// Minimum cell size to break across pages. + min_hbreak: usize, + + box_chars: &'static BoxChars, + + params: Params, + n_objects: usize, + lines: Vec, +} + +impl Default for TextRenderer { + fn default() -> Self { + Self::new(&TextRendererOptions::default()) + } +} + +impl TextRenderer { + pub fn new(config: &TextRendererOptions) -> Self { + let width = config.width.unwrap_or(usize::MAX); + Self { + emphasis: config.emphasis, + width, + min_hbreak: 20, + box_chars: config.boxes.box_chars(), + n_objects: 0, + params: Params { + size: Coord2::new(width, usize::MAX), + font_size: EnumMap::from_fn(|_| 1), + line_widths: EnumMap::from_fn(|stroke| if stroke == Stroke::None { 0 } else { 1 }), + px_size: None, + min_break: EnumMap::default(), + supports_margins: false, + rtl: false, + printing: true, + can_adjust_break: false, + can_scale: false, + }, + lines: Vec::new(), + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Enum)] +enum Line { + None, + Dashed, + Single, + Double, +} + +impl From for Line { + fn from(stroke: Stroke) -> Self { + match stroke { + Stroke::None => Self::None, + Stroke::Solid | Stroke::Thick | Stroke::Thin => Self::Single, + Stroke::Dashed => Self::Dashed, + Stroke::Double => Self::Double, + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Enum)] +struct Lines { + r: Line, + b: Line, + l: Line, + t: Line, +} + +#[derive(Default)] +struct BoxChars(EnumMap); + +impl BoxChars { + fn put(&mut self, r: Line, b: Line, l: Line, chars: [char; 4]) { + use Line::*; + for (t, c) in [None, Dashed, Single, Double] + .into_iter() + .zip(chars.into_iter()) + { + self.0[Lines { r, b, l, t }] = c; + } + } +} + +impl Index for BoxChars { + type Output = char; + + fn index(&self, lines: Lines) -> &Self::Output { + &self.0[lines] + } +} + +static ASCII_BOX: LazyLock = LazyLock::new(|| { + let mut ascii_box = BoxChars::default(); + let n = Line::None; + let d = Line::Dashed; + use Line::{Double as D, Single as S}; + ascii_box.put(n, n, n, [' ', '|', '|', '#']); + ascii_box.put(n, n, d, ['-', '+', '+', '#']); + ascii_box.put(n, n, S, ['-', '+', '+', '#']); + ascii_box.put(n, n, D, ['=', '#', '#', '#']); + ascii_box.put(n, d, n, ['|', '|', '|', '#']); + ascii_box.put(n, d, d, ['+', '+', '+', '#']); + ascii_box.put(n, d, S, ['+', '+', '+', '#']); + ascii_box.put(n, d, D, ['#', '#', '#', '#']); + ascii_box.put(n, S, n, ['|', '|', '|', '#']); + ascii_box.put(n, S, d, ['+', '+', '+', '#']); + ascii_box.put(n, S, S, ['+', '+', '+', '#']); + ascii_box.put(n, S, D, ['#', '#', '#', '#']); + ascii_box.put(n, D, n, ['#', '#', '#', '#']); + ascii_box.put(n, D, d, ['#', '#', '#', '#']); + ascii_box.put(n, D, S, ['#', '#', '#', '#']); + ascii_box.put(n, D, D, ['#', '#', '#', '#']); + ascii_box.put(d, n, n, ['-', '+', '+', '#']); + ascii_box.put(d, n, d, ['-', '+', '+', '#']); + ascii_box.put(d, n, S, ['-', '+', '+', '#']); + ascii_box.put(d, n, D, ['#', '#', '#', '#']); + ascii_box.put(d, d, n, ['+', '+', '+', '#']); + ascii_box.put(d, d, d, ['+', '+', '+', '#']); + ascii_box.put(d, d, S, ['+', '+', '+', '#']); + ascii_box.put(d, d, D, ['#', '#', '#', '#']); + ascii_box.put(d, S, n, ['+', '+', '+', '#']); + ascii_box.put(d, S, d, ['+', '+', '+', '#']); + ascii_box.put(d, S, S, ['+', '+', '+', '#']); + ascii_box.put(d, S, D, ['#', '#', '#', '#']); + ascii_box.put(d, D, n, ['#', '#', '#', '#']); + ascii_box.put(d, D, d, ['#', '#', '#', '#']); + ascii_box.put(d, D, S, ['#', '#', '#', '#']); + ascii_box.put(d, D, D, ['#', '#', '#', '#']); + ascii_box.put(S, n, n, ['-', '+', '+', '#']); + ascii_box.put(S, n, d, ['-', '+', '+', '#']); + ascii_box.put(S, n, S, ['-', '+', '+', '#']); + ascii_box.put(S, n, D, ['#', '#', '#', '#']); + ascii_box.put(S, d, n, ['+', '+', '+', '#']); + ascii_box.put(S, d, d, ['+', '+', '+', '#']); + ascii_box.put(S, d, S, ['+', '+', '+', '#']); + ascii_box.put(S, d, D, ['#', '#', '#', '#']); + ascii_box.put(S, S, n, ['+', '+', '+', '#']); + ascii_box.put(S, S, d, ['+', '+', '+', '#']); + ascii_box.put(S, S, S, ['+', '+', '+', '#']); + ascii_box.put(S, S, D, ['#', '#', '#', '#']); + ascii_box.put(S, D, n, ['#', '#', '#', '#']); + ascii_box.put(S, D, d, ['#', '#', '#', '#']); + ascii_box.put(S, D, S, ['#', '#', '#', '#']); + ascii_box.put(S, D, D, ['#', '#', '#', '#']); + ascii_box.put(D, n, n, ['=', '#', '#', '#']); + ascii_box.put(D, n, d, ['#', '#', '#', '#']); + ascii_box.put(D, n, S, ['#', '#', '#', '#']); + ascii_box.put(D, n, D, ['=', '#', '#', '#']); + ascii_box.put(D, d, n, ['#', '#', '#', '#']); + ascii_box.put(D, d, d, ['#', '#', '#', '#']); + ascii_box.put(D, d, S, ['#', '#', '#', '#']); + ascii_box.put(D, d, D, ['#', '#', '#', '#']); + ascii_box.put(D, S, n, ['#', '#', '#', '#']); + ascii_box.put(D, S, d, ['#', '#', '#', '#']); + ascii_box.put(D, S, S, ['#', '#', '#', '#']); + ascii_box.put(D, S, D, ['#', '#', '#', '#']); + ascii_box.put(D, D, n, ['#', '#', '#', '#']); + ascii_box.put(D, D, d, ['#', '#', '#', '#']); + ascii_box.put(D, D, S, ['#', '#', '#', '#']); + ascii_box.put(D, D, D, ['#', '#', '#', '#']); + ascii_box +}); + +static UNICODE_BOX: LazyLock = LazyLock::new(|| { + let mut unicode_box = BoxChars::default(); + let n = Line::None; + let d = Line::Dashed; + use Line::{Double as D, Single as S}; + unicode_box.put(n, n, n, [' ', '╵', '╵', '║']); + unicode_box.put(n, n, d, ['╌', '╯', '╯', '╜']); + unicode_box.put(n, n, S, ['╴', '╯', '╯', '╜']); + unicode_box.put(n, n, D, ['═', '╛', '╛', '╝']); + unicode_box.put(n, S, n, ['╷', '│', '│', '║']); + unicode_box.put(n, S, d, ['╮', '┤', '┤', '╢']); + unicode_box.put(n, S, S, ['╮', '┤', '┤', '╢']); + unicode_box.put(n, S, D, ['╕', '╡', '╡', '╣']); + unicode_box.put(n, d, n, ['╷', '┊', '│', '║']); + unicode_box.put(n, d, d, ['╮', '┤', '┤', '╢']); + unicode_box.put(n, d, S, ['╮', '┤', '┤', '╢']); + unicode_box.put(n, d, D, ['╕', '╡', '╡', '╣']); + unicode_box.put(n, D, n, ['║', '║', '║', '║']); + unicode_box.put(n, D, d, ['╖', '╢', '╢', '╢']); + unicode_box.put(n, D, S, ['╖', '╢', '╢', '╢']); + unicode_box.put(n, D, D, ['╗', '╣', '╣', '╣']); + unicode_box.put(d, n, n, ['╌', '╰', '╰', '╙']); + unicode_box.put(d, n, d, ['╌', '┴', '┴', '╨']); + unicode_box.put(d, n, S, ['─', '┴', '┴', '╨']); + unicode_box.put(d, n, D, ['═', '╧', '╧', '╩']); + unicode_box.put(d, d, n, ['╭', '├', '├', '╟']); + unicode_box.put(d, d, d, ['┬', '+', '┼', '╪']); + unicode_box.put(d, d, S, ['┬', '┼', '┼', '╪']); + unicode_box.put(d, d, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(d, S, n, ['╭', '├', '├', '╟']); + unicode_box.put(d, S, d, ['┬', '┼', '┼', '╪']); + unicode_box.put(d, S, S, ['┬', '┼', '┼', '╪']); + unicode_box.put(d, S, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(d, D, n, ['╓', '╟', '╟', '╟']); + unicode_box.put(d, D, d, ['╥', '╫', '╫', '╫']); + unicode_box.put(d, D, S, ['╥', '╫', '╫', '╫']); + unicode_box.put(d, D, D, ['╦', '╬', '╬', '╬']); + unicode_box.put(S, n, n, ['╶', '╰', '╰', '╙']); + unicode_box.put(S, n, d, ['─', '┴', '┴', '╨']); + unicode_box.put(S, n, S, ['─', '┴', '┴', '╨']); + unicode_box.put(S, n, D, ['═', '╧', '╧', '╩']); + unicode_box.put(S, d, n, ['╭', '├', '├', '╟']); + unicode_box.put(S, d, d, ['┬', '┼', '┼', '╪']); + unicode_box.put(S, d, S, ['┬', '┼', '┼', '╪']); + unicode_box.put(S, d, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(S, S, n, ['╭', '├', '├', '╟']); + unicode_box.put(S, S, d, ['┬', '┼', '┼', '╪']); + unicode_box.put(S, S, S, ['┬', '┼', '┼', '╪']); + unicode_box.put(S, S, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(S, D, n, ['╓', '╟', '╟', '╟']); + unicode_box.put(S, D, d, ['╥', '╫', '╫', '╫']); + unicode_box.put(S, D, S, ['╥', '╫', '╫', '╫']); + unicode_box.put(S, D, D, ['╦', '╬', '╬', '╬']); + unicode_box.put(D, n, n, ['═', '╘', '╘', '╚']); + unicode_box.put(D, n, d, ['═', '╧', '╧', '╩']); + unicode_box.put(D, n, S, ['═', '╧', '╧', '╩']); + unicode_box.put(D, n, D, ['═', '╧', '╧', '╩']); + unicode_box.put(D, d, n, ['╒', '╞', '╞', '╠']); + unicode_box.put(D, d, d, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, d, S, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, d, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, S, n, ['╒', '╞', '╞', '╠']); + unicode_box.put(D, S, d, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, S, S, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, S, D, ['╤', '╪', '╪', '╬']); + unicode_box.put(D, D, n, ['╔', '╠', '╠', '╠']); + unicode_box.put(D, D, d, ['╠', '╬', '╬', '╬']); + unicode_box.put(D, D, S, ['╠', '╬', '╬', '╬']); + unicode_box.put(D, D, D, ['╦', '╬', '╬', '╬']); + unicode_box +}); + +impl PivotTable { + pub fn display(&self) -> DisplayPivotTable<'_> { + DisplayPivotTable::new(self) + } +} + +impl Display for PivotTable { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.display()) + } +} + +pub struct DisplayPivotTable<'a> { + pt: &'a PivotTable, +} + +impl<'a> DisplayPivotTable<'a> { + fn new(pt: &'a PivotTable) -> Self { + Self { pt } + } +} + +impl Display for DisplayPivotTable<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + TextRenderer::default().render_table(self.pt, f) + } +} + +impl Display for Item { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + TextRenderer::default().render(self, f) + } +} + +pub struct TextDriver { + file: BufWriter, + renderer: TextRenderer, +} + +impl TextDriver { + pub fn new(config: &TextConfig) -> std::io::Result { + Ok(Self { + file: BufWriter::new(match &config.file { + Some(file) => File::create(file)?, + None => File::options().write(true).open("/dev/stdout")?, + }), + renderer: TextRenderer::new(&config.options), + }) + } +} + +impl TextRenderer { + fn render(&mut self, item: &Item, writer: &mut W) -> FmtResult + where + W: FmtWrite, + { + match &item.details { + Details::Chart => todo!(), + Details::Image => todo!(), + Details::Group(children) => { + for (index, child) in children.iter().enumerate() { + if index > 0 { + writeln!(writer)?; + } + self.render(child, writer)?; + } + Ok(()) + } + Details::Message(_diagnostic) => todo!(), + Details::PageBreak => Ok(()), + Details::Table(pivot_table) => self.render_table(pivot_table, writer), + Details::Text(text) => self.render_table(&PivotTable::from((**text).clone()), writer), + } + } + + fn render_table(&mut self, table: &PivotTable, writer: &mut W) -> FmtResult + where + W: FmtWrite, + { + for (index, layer_indexes) in table.layers(true).enumerate() { + if index > 0 { + writeln!(writer)?; + } + + let mut pager = Pager::new(self, table, Some(layer_indexes.as_slice())); + while pager.has_next(self) { + pager.draw_next(self, usize::MAX); + for line in self.lines.drain(..) { + writeln!(writer, "{line}")?; + } + } + } + Ok(()) + } + + fn layout_cell(&self, text: &str, bb: Rect2) -> Coord2 { + if text.is_empty() { + return Coord2::default(); + } + + use Axis2::*; + let breaks = new_line_breaks(text, bb[X].len()); + let mut size = Coord2::new(0, 0); + for text in breaks.take(bb[Y].len()) { + let width = text.width(); + if width > size[X] { + size[X] = width; + } + size[Y] += 1; + } + size + } + + fn get_line(&mut self, y: usize) -> &mut TextLine { + if y >= self.lines.len() { + self.lines.resize(y + 1, TextLine::new()); + } + &mut self.lines[y] + } +} + +struct LineBreaks<'a, B> +where + B: Iterator + Clone + 'a, +{ + text: &'a str, + max_width: usize, + indexes: Range, + width: usize, + saved: Option<(usize, BreakOpportunity)>, + breaks: B, + trailing_newlines: usize, +} + +impl<'a, B> Iterator for LineBreaks<'a, B> +where + B: Iterator + Clone + 'a, +{ + type Item = &'a str; + + fn next(&mut self) -> Option { + while let Some((postindex, opportunity)) = self.saved.take().or_else(|| self.breaks.next()) + { + let index = if postindex != self.text.len() { + self.text[..postindex].char_indices().next_back().unwrap().0 + } else { + postindex + }; + if index <= self.indexes.end { + continue; + } + + let segment_width = self.text[self.indexes.end..index].width(); + if self.width == 0 || self.width + segment_width <= self.max_width { + // Add this segment to the current line. + self.width += segment_width; + self.indexes.end = index; + + // If this was a new-line, we're done. + if opportunity == BreakOpportunity::Mandatory { + let segment = self.text[self.indexes.clone()].trim_end_matches('\n'); + self.indexes = postindex..postindex; + self.width = 0; + return Some(segment); + } + } else { + // Won't fit. Return what we've got and save this segment for next time. + // + // We trim trailing spaces from the line we return, and leading + // spaces from the position where we resume. + let segment = self.text[self.indexes.clone()].trim_end(); + + let start = self.text[self.indexes.end..].trim_start_matches([' ', '\t']); + let start_index = self.text.len() - start.len(); + self.indexes = start_index..start_index; + self.width = 0; + self.saved = Some((postindex, opportunity)); + return Some(segment); + } + } + if self.trailing_newlines > 1 { + self.trailing_newlines -= 1; + Some("") + } else { + None + } + } +} + +fn new_line_breaks( + text: &str, + width: usize, +) -> LineBreaks<'_, impl Iterator + Clone + '_> { + // Trim trailing new-lines from the text, because the linebreaking algorithm + // treats them as if they have width. That is, if you break `"a b c\na b + // c\n"` with a 5-character width, then you end up with: + // + // ```text + // a b c + // a b + // c + // ``` + // + // So, we trim trailing new-lines and then add in extra blank lines at the + // end if necessary. + // + // (The linebreaking algorithm treats new-lines in the middle of the text in + // a normal way, though.) + let trimmed = text.trim_end_matches('\n'); + LineBreaks { + text: trimmed, + max_width: width, + indexes: 0..0, + width: 0, + saved: None, + breaks: linebreaks(trimmed), + trailing_newlines: text.len() - trimmed.len(), + } +} + +impl Driver for TextDriver { + fn name(&self) -> Cow<'static, str> { + Cow::from("text") + } + + fn write(&mut self, item: &Arc) { + let _ = self.renderer.render(item, &mut FmtAdapter(&mut self.file)); + } +} + +struct FmtAdapter(W); + +impl FmtWrite for FmtAdapter +where + W: IoWrite, +{ + fn write_str(&mut self, s: &str) -> FmtResult { + self.0.write_all(s.as_bytes()).map_err(|_| FmtError) + } +} + +impl Device for TextRenderer { + fn params(&self) -> &Params { + &self.params + } + + fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap { + let text = cell.display().to_string(); + enum_map![ + Extreme::Min => self.layout_cell(&text, Rect2::new(0..1, 0..usize::MAX)).x(), + Extreme::Max => self.layout_cell(&text, Rect2::new(0..usize::MAX, 0..usize::MAX)).x(), + ] + } + + fn measure_cell_height(&self, cell: &DrawCell, width: usize) -> usize { + let text = cell.display().to_string(); + self.layout_cell(&text, Rect2::new(0..width, 0..usize::MAX)) + .y() + } + + fn adjust_break(&self, _cell: &Content, _size: Coord2) -> usize { + unreachable!() + } + + fn draw_line(&mut self, bb: Rect2, styles: EnumMap) { + use Axis2::*; + let x = bb[X].start.max(0)..bb[X].end.min(self.width); + let y = bb[Y].start.max(0)..bb[Y].end; + if x.is_empty() || x.end >= self.width { + return; + } + + let lines = Lines { + l: styles[Y][0].stroke.into(), + r: styles[Y][1].stroke.into(), + t: styles[X][0].stroke.into(), + b: styles[X][1].stroke.into(), + }; + let c = self.box_chars[lines]; + for y in y { + self.get_line(y).put_multiple(x.start, c, x.len()); + } + } + + fn draw_cell( + &mut self, + cell: &DrawCell, + _alternate_row: bool, + bb: Rect2, + valign_offset: usize, + _spill: EnumMap, + clip: &Rect2, + ) { + let display = cell.display(); + let text = display.to_string(); + let horz_align = cell.horz_align(&display); + + use Axis2::*; + let breaks = new_line_breaks(&text, bb[X].len()); + for (text, y) in breaks.zip(bb[Y].start + valign_offset..bb[Y].end) { + let width = text.width(); + if !clip[Y].contains(&y) { + continue; + } + + let x = match horz_align { + HorzAlign::Right | HorzAlign::Decimal { .. } => bb[X].end - width, + HorzAlign::Left => bb[X].start, + HorzAlign::Center => (bb[X].start + bb[X].end - width).div_ceil(2), + }; + let Some((x, text)) = clip_text(text, &(x..x + width), &clip[X]) else { + continue; + }; + + let text = if self.emphasis { + Emphasis::from(&cell.style.font_style).apply(text) + } else { + Cow::from(text) + }; + self.get_line(y).put(x, &text); + } + } + + fn scale(&mut self, _factor: f64) { + unimplemented!() + } +} + +#[cfg(test)] +mod tests { + use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + + use crate::output::drivers::text::new_line_breaks; + + #[test] + fn unicode_width() { + // `\n` is a control character, so [UnicodeWidthChar] considers it to + // have no width. + assert_eq!('\n'.width(), None); + + // But [UnicodeWidthStr] in unicode-width 0.1.14+ has a different idea. + assert_eq!("\n".width(), 1); + assert_eq!("\r\n".width(), 1); + } + + #[track_caller] + fn test_line_breaks(input: &str, width: usize, expected: Vec<&str>) { + let actual = new_line_breaks(input, width).collect::>(); + if expected != actual { + panic!( + "filling {input:?} to {width} columns:\nexpected: {expected:?}\nactual: {actual:?}" + ); + } + } + #[test] + fn line_breaks() { + test_line_breaks( + "One line of text\nOne line of text\n", + 16, + vec!["One line of text", "One line of text"], + ); + test_line_breaks("a b c\na b c\na b c\n", 5, vec!["a b c", "a b c", "a b c"]); + for width in 0..=6 { + test_line_breaks("abc def ghi", width, vec!["abc", "def", "ghi"]); + } + for width in 7..=10 { + test_line_breaks("abc def ghi", width, vec!["abc def", "ghi"]); + } + test_line_breaks("abc def ghi", 11, vec!["abc def ghi"]); + + for width in 0..=6 { + test_line_breaks("abc def ghi", width, vec!["abc", "def", "ghi"]); + } + test_line_breaks("abc def ghi", 7, vec!["abc", "def ghi"]); + for width in 8..=11 { + test_line_breaks("abc def ghi", width, vec!["abc def", "ghi"]); + } + test_line_breaks("abc def ghi", 12, vec!["abc def ghi"]); + + test_line_breaks("abc\ndef\nghi", 2, vec!["abc", "def", "ghi"]); + } +} diff --git a/rust/pspp/src/output/drivers/text/text_line.rs b/rust/pspp/src/output/drivers/text/text_line.rs new file mode 100644 index 0000000000..e4d7c5c370 --- /dev/null +++ b/rust/pspp/src/output/drivers/text/text_line.rs @@ -0,0 +1,610 @@ +// PSPP - a program for statistical analysis. +// Copyright (C) 2025 Free Software Foundation, Inc. +// +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU General Public License as published by the Free Software +// Foundation, either version 3 of the License, or (at your option) any later +// version. +// +// This program is distributed in the hope that it will be useful, but WITHOUT +// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +// details. +// +// You should have received a copy of the GNU General Public License along with +// this program. If not, see . + +use enum_iterator::Sequence; +use std::{ + borrow::Cow, + cmp::Ordering, + fmt::{Debug, Display}, + ops::Range, +}; + +use unicode_width::UnicodeWidthChar; + +use crate::output::pivot::FontStyle; + +/// A line of text, encoded in UTF-8, with support functions that properly +/// handle double-width characters and backspaces. +/// +/// Designed to make appending text fast, and access and modification of other +/// column positions possible. +#[derive(Clone, Default)] +pub struct TextLine { + /// Content. + string: String, + + /// Display width, in character positions. + width: usize, +} + +impl TextLine { + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.string.clear(); + self.width = 0; + } + + /// Changes the width of this line to `x` columns. If `x` is longer than + /// the current width, extends the line with spaces. If `x` is shorter than + /// the current width, removes trailing characters. + pub fn resize(&mut self, x: usize) { + match x.cmp(&self.width) { + Ordering::Greater => self.string.extend((self.width..x).map(|_| ' ')), + Ordering::Less => { + let pos = self.find_pos(x); + self.string.truncate(pos.offsets.start); + if x > pos.offsets.start { + self.string.extend((pos.offsets.start..x).map(|_| '?')); + } + } + Ordering::Equal => return, + } + self.width = x; + } + + fn put_closure(&mut self, x0: usize, w: usize, push_str: F) + where + F: FnOnce(&mut String), + { + let x1 = x0 + w; + if w == 0 { + // Nothing to do. + } else if x0 >= self.width { + // The common case: adding new characters at the end of a line. + self.string.extend((self.width..x0).map(|_| ' ')); + push_str(&mut self.string); + self.width = x1; + } else if x1 >= self.width { + let p0 = self.find_pos(x0); + + // If a double-width character occupies both `x0 - 1` and `x0`, then + // replace its first character width by `?`. + self.string.truncate(p0.offsets.start); + self.string.extend((p0.columns.start..x0).map(|_| '?')); + push_str(&mut self.string); + self.width = x1; + } else { + let span = self.find_span(x0, x1); + let tail = self.string.split_off(span.offsets.end); + self.string.truncate(span.offsets.start); + self.string.extend((span.columns.start..x0).map(|_| '?')); + push_str(&mut self.string); + self.string.extend((x1..span.columns.end).map(|_| '?')); + self.string.push_str(&tail); + } + } + + pub fn put(&mut self, x0: usize, s: &str) { + self.string.reserve(s.len()); + self.put_closure(x0, Widths::new(s).sum(), |dst| dst.push_str(s)); + } + + pub fn put_multiple(&mut self, x0: usize, c: char, n: usize) { + self.string.reserve(c.len_utf8() * n); + self.put_closure(x0, c.width().unwrap() * n, |dst| { + (0..n).for_each(|_| dst.push(c)) + }); + } + + fn find_span(&self, x0: usize, x1: usize) -> Position { + debug_assert!(x1 > x0); + let p0 = self.find_pos(x0); + let p1 = self.find_pos(x1 - 1); + Position { + columns: p0.columns.start..p1.columns.end, + offsets: p0.offsets.start..p1.offsets.end, + } + } + + // Returns the [Position] that contains column `target_x`. + fn find_pos(&self, target_x: usize) -> Position { + let mut x = 0; + let mut ofs = 0; + let mut widths = Widths::new(&self.string); + while let Some(w) = widths.next() { + if x + w > target_x { + return Position { + columns: x..x + w, + offsets: ofs..widths.offset(), + }; + } + ofs = widths.offset(); + x += w; + } + + // This can happen if there are non-printable characters in a line. + Position { + columns: x..x, + offsets: ofs..ofs, + } + } + + pub fn str(&self) -> &str { + &self.string + } +} + +impl Display for TextLine { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.string) + } +} + +/// Position of one or more characters within a [TextLine]. +#[derive(Debug)] +struct Position { + /// 0-based display columns. + columns: Range, + + /// Byte offests. + offsets: Range, +} + +/// Iterates through the column widths in a string. +struct Widths<'a> { + s: &'a str, + base: &'a str, +} + +impl<'a> Widths<'a> { + fn new(s: &'a str) -> Self { + Self { s, base: s } + } + + /// Returns the amount of the string remaining to be visited. + fn as_str(&self) -> &str { + self.s + } + + // Returns the offset into the original string of the characters remaining + // to be visited. + fn offset(&self) -> usize { + self.base.len() - self.s.len() + } +} + +impl Iterator for Widths<'_> { + type Item = usize; + + fn next(&mut self) -> Option { + let mut iter = self.s.char_indices(); + let (_, mut c) = iter.next()?; + while iter.as_str().starts_with('\x08') { + iter.next(); + c = match iter.next() { + Some((_, c)) => c, + _ => { + self.s = iter.as_str(); + return Some(0); + } + }; + } + + let w = c.width().unwrap_or_default(); + if w == 0 { + self.s = iter.as_str(); + return Some(0); + } + + for (index, c) in iter { + if c.width().is_some_and(|width| width > 0) { + self.s = &self.s[index..]; + return Some(w); + } + } + self.s = ""; + Some(w) + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Sequence)] +pub struct Emphasis { + pub bold: bool, + pub underline: bool, +} + +impl From<&FontStyle> for Emphasis { + fn from(style: &FontStyle) -> Self { + Self { + bold: style.bold, + underline: style.underline, + } + } +} + +impl Emphasis { + const fn plain() -> Self { + Self { + bold: false, + underline: false, + } + } + pub fn is_plain(&self) -> bool { + *self == Self::plain() + } + pub fn apply<'a>(&self, s: &'a str) -> Cow<'a, str> { + if self.is_plain() { + Cow::from(s) + } else { + let mut output = String::with_capacity( + s.len() * (1 + self.bold as usize * 2 + self.underline as usize * 2), + ); + for c in s.chars() { + if self.bold { + output.push(c); + output.push('\x08'); + } + if self.underline { + output.push('_'); + output.push('\x08'); + } + output.push(c); + } + Cow::from(output) + } + } +} + +impl Debug for Emphasis { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Self { + bold: false, + underline: false, + } => "plain", + Self { + bold: true, + underline: false, + } => "bold", + Self { + bold: false, + underline: true, + } => "underline", + Self { + bold: true, + underline: true, + } => "bold+underline", + } + ) + } +} + +pub fn clip_text<'a>( + text: &'a str, + bb: &Range, + clip: &Range, +) -> Option<(usize, &'a str)> { + let mut x = bb.start; + let mut width = bb.len(); + + let mut iter = text.chars(); + while x < clip.start { + let c = iter.next()?; + if let Some(w) = c.width() { + x += w; + width = width.checked_sub(w)?; + } + } + if x + width > clip.end { + if x >= clip.end { + return None; + } + + while x + width > clip.end { + let c = iter.next_back()?; + if let Some(w) = c.width() { + width = width.checked_sub(w)?; + } + } + } + Some((x, iter.as_str())) +} + +#[cfg(test)] +mod tests { + use super::{Emphasis, TextLine}; + use enum_iterator::all; + + #[test] + fn overwrite_rest_of_line() { + for lowercase in all::() { + for uppercase in all::() { + let mut line = TextLine::new(); + line.put(0, &lowercase.apply("abc")); + line.put(1, &uppercase.apply("BCD")); + assert_eq!( + line.str(), + &format!("{}{}", lowercase.apply("a"), uppercase.apply("BCD")), + "uppercase={uppercase:?} lowercase={lowercase:?}" + ); + } + } + } + + #[test] + fn overwrite_partial_line() { + for lowercase in all::() { + for uppercase in all::() { + let mut line = TextLine::new(); + // Produces `AbCDEf`. + line.put(0, &lowercase.apply("abcdef")); + line.put(0, &uppercase.apply("A")); + line.put(2, &uppercase.apply("CDE")); + assert_eq!( + line.str().replace('\x08', "#"), + format!( + "{}{}{}{}", + uppercase.apply("A"), + lowercase.apply("b"), + uppercase.apply("CDE"), + lowercase.apply("f") + ) + .replace('\x08', "#"), + "uppercase={uppercase:?} lowercase={lowercase:?}" + ); + } + } + } + + #[test] + fn overwrite_rest_with_double_width() { + for lowercase in all::() { + for hiragana in all::() { + let mut line = TextLine::new(); + // Produces `kaきくけ"`. + line.put(0, &lowercase.apply("kakiku")); + line.put(2, &hiragana.apply("きくけ")); + assert_eq!( + line.str(), + &format!("{}{}", lowercase.apply("ka"), hiragana.apply("きくけ")), + "lowercase={lowercase:?} hiragana={hiragana:?}" + ); + } + } + } + + #[test] + fn overwrite_partial_with_double_width() { + for lowercase in all::() { + for hiragana in all::() { + let mut line = TextLine::new(); + // Produces `かkiくけko". + line.put(0, &lowercase.apply("kakikukeko")); + line.put(0, &hiragana.apply("か")); + line.put(4, &hiragana.apply("くけ")); + assert_eq!( + line.str(), + &format!( + "{}{}{}{}", + hiragana.apply("か"), + lowercase.apply("ki"), + hiragana.apply("くけ"), + lowercase.apply("ko") + ), + "lowercase={lowercase:?} hiragana={hiragana:?}" + ); + } + } + } + + /// Overwrite rest of line, aligned double-width over double-width + #[test] + fn aligned_double_width_rest_of_line() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `あきくけ`. + line.put(0, &bottom.apply("あいう")); + line.put(2, &top.apply("きくけ")); + assert_eq!( + line.str(), + &format!("{}{}", bottom.apply("あ"), top.apply("きくけ")), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite rest of line, misaligned double-width over double-width + #[test] + fn misaligned_double_width_rest_of_line() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `あきくけ`. + line.put(0, &bottom.apply("あいう")); + line.put(3, &top.apply("きくけ")); + assert_eq!( + line.str(), + &format!("{}?{}", bottom.apply("あ"), top.apply("きくけ")), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite partial line, aligned double-width over double-width + #[test] + fn aligned_double_width_partial() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `かいくけお`. + line.put(0, &bottom.apply("あいうえお")); + line.put(0, &top.apply("か")); + line.put(4, &top.apply("くけ")); + assert_eq!( + line.str(), + &format!( + "{}{}{}{}", + top.apply("か"), + bottom.apply("い"), + top.apply("くけ"), + bottom.apply("お") + ), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite partial line, misaligned double-width over double-width + #[test] + fn misaligned_double_width_partial() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `?か?うえおさ`. + line.put(0, &bottom.apply("あいうえおさ")); + line.put(1, &top.apply("か")); + assert_eq!( + line.str(), + &format!("?{}?{}", top.apply("か"), bottom.apply("うえおさ"),), + "bottom={bottom:?} top={top:?}" + ); + + // Produces `?か??くけ?さ`. + line.put(5, &top.apply("くけ")); + assert_eq!( + line.str(), + &format!( + "?{}??{}?{}", + top.apply("か"), + top.apply("くけ"), + bottom.apply("さ") + ), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite rest of line, aligned single-width over double-width. + #[test] + fn aligned_rest_single_over_double() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `あkikuko`. + line.put(0, &bottom.apply("あいう")); + line.put(2, &top.apply("kikuko")); + assert_eq!( + line.str(), + &format!("{}{}", bottom.apply("あ"), top.apply("kikuko"),), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite rest of line, misaligned single-width over double-width. + #[test] + fn misaligned_rest_single_over_double() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `あ?kikuko`. + line.put(0, &bottom.apply("あいう")); + line.put(3, &top.apply("kikuko")); + assert_eq!( + line.str(), + &format!("{}?{}", bottom.apply("あ"), top.apply("kikuko"),), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite partial line, aligned single-width over double-width. + #[test] + fn aligned_partial_single_over_double() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `kaいうえお`. + line.put(0, &bottom.apply("あいうえお")); + line.put(0, &top.apply("ka")); + assert_eq!( + line.str(), + &format!("{}{}", top.apply("ka"), bottom.apply("いうえお"),), + "bottom={bottom:?} top={top:?}" + ); + + // Produces `kaいkukeお`. + line.put(4, &top.apply("kuke")); + assert_eq!( + line.str(), + &format!( + "{}{}{}{}", + top.apply("ka"), + bottom.apply("い"), + top.apply("kuke"), + bottom.apply("お") + ), + "bottom={bottom:?} top={top:?}" + ); + } + } + } + + /// Overwrite partial line, misaligned single-width over double-width. + #[test] + fn misaligned_partial_single_over_double() { + for bottom in all::() { + for top in all::() { + let mut line = TextLine::new(); + // Produces `?aいうえおさ`. + line.put(0, &bottom.apply("あいうえおさ")); + line.put(1, &top.apply("a")); + assert_eq!( + line.str(), + &format!("?{}{}", top.apply("a"), bottom.apply("いうえおさ"),), + "bottom={bottom:?} top={top:?}" + ); + + // Produces `?aい?kuke?さ`. + line.put(5, &top.apply("kuke")); + assert_eq!( + line.str(), + &format!( + "?{}{}?{}?{}", + top.apply("a"), + bottom.apply("い"), + top.apply("kuke"), + bottom.apply("さ") + ), + "bottom={bottom:?} top={top:?}" + ); + } + } + } +} diff --git a/rust/pspp/src/output/html.rs b/rust/pspp/src/output/html.rs deleted file mode 100644 index 949724b9af..0000000000 --- a/rust/pspp/src/output/html.rs +++ /dev/null @@ -1,500 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use std::{ - borrow::Cow, - fmt::{Display, Write as _}, - fs::File, - io::Write, - path::PathBuf, - sync::Arc, -}; - -use serde::{Deserialize, Serialize}; -use smallstr::SmallString; - -use crate::output::{ - Details, Item, - driver::Driver, - pivot::{Axis2, BorderStyle, Color, Coord2, HorzAlign, PivotTable, Rect2, Stroke, VertAlign}, - table::{DrawCell, Table}, -}; - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct HtmlConfig { - file: PathBuf, -} - -pub struct HtmlDriver { - writer: W, - fg: Color, - bg: Color, -} - -impl Stroke { - fn as_css(&self) -> Option<&'static str> { - match self { - Stroke::None => None, - Stroke::Solid => Some("1pt solid"), - Stroke::Dashed => Some("1pt dashed"), - Stroke::Thick => Some("2pt solid"), - Stroke::Thin => Some("0.5pt solid"), - Stroke::Double => Some("double"), - } - } -} - -impl HtmlDriver { - pub fn new(config: &HtmlConfig) -> std::io::Result { - Ok(Self::for_writer(File::create(&config.file)?)) - } -} - -impl HtmlDriver -where - W: Write, -{ - pub fn for_writer(mut writer: W) -> Self { - let _ = put_header(&mut writer); - Self { - fg: Color::BLACK, - bg: Color::WHITE, - writer, - } - } - - fn render(&mut self, pivot_table: &PivotTable) -> std::io::Result<()> { - for layer_indexes in pivot_table.layers(true) { - let output = pivot_table.output(&layer_indexes, false); - write!(&mut self.writer, "")?; - - if let Some(title) = output.title { - let cell = title.get(Coord2::new(0, 0)); - self.put_cell( - DrawCell::new(cell.inner(), &title), - Rect2::new(0..1, 0..1), - false, - "caption", - None, - )?; - } - - if let Some(layers) = output.layers { - writeln!(&mut self.writer, "")?; - for cell in layers.cells() { - writeln!(&mut self.writer, "")?; - self.put_cell( - DrawCell::new(cell.inner(), &layers), - Rect2::new(0..output.body.n[Axis2::X], 0..1), - false, - "td", - None, - )?; - writeln!(&mut self.writer, "")?; - } - writeln!(&mut self.writer, "")?; - } - - writeln!(&mut self.writer, "")?; - for y in 0..output.body.n.y() { - writeln!(&mut self.writer, "")?; - for x in output.body.iter_x(y) { - let cell = output.body.get(Coord2::new(x, y)); - if cell.is_top_left() { - let is_header = x < output.body.h[Axis2::X] || y < output.body.h[Axis2::Y]; - let tag = if is_header { "th" } else { "td" }; - let alternate_row = y - .checked_sub(output.body.h[Axis2::Y]) - .is_some_and(|y| y % 2 == 1); - self.put_cell( - DrawCell::new(cell.inner(), &output.body), - cell.rect(), - alternate_row, - tag, - Some(&output.body), - )?; - } - } - writeln!(&mut self.writer, "")?; - } - writeln!(&mut self.writer, "")?; - - if output.caption.is_some() || output.footnotes.is_some() { - writeln!(&mut self.writer, "")?; - writeln!(&mut self.writer, "")?; - if let Some(caption) = output.caption { - self.put_cell( - DrawCell::new(caption.get(Coord2::new(0, 0)).inner(), &caption), - Rect2::new(0..output.body.n[Axis2::X], 0..1), - false, - "td", - None, - )?; - } - writeln!(&mut self.writer, "")?; - - if let Some(footnotes) = output.footnotes { - for cell in footnotes.cells() { - writeln!(&mut self.writer, "")?; - self.put_cell( - DrawCell::new(cell.inner(), &footnotes), - Rect2::new(0..output.body.n[Axis2::X], 0..1), - false, - "td", - None, - )?; - writeln!(&mut self.writer, "")?; - } - } - writeln!(&mut self.writer, "")?; - } - } - Ok(()) - } - - fn put_cell( - &mut self, - cell: DrawCell<'_>, - rect: Rect2, - alternate_row: bool, - tag: &str, - table: Option<&Table>, - ) -> std::io::Result<()> { - write!(&mut self.writer, "<{tag}")?; - let (body, suffixes) = cell.display().split_suffixes(); - - let mut style = String::new(); - let horz_align = match cell.horz_align(&body) { - HorzAlign::Right | HorzAlign::Decimal { .. } => Some("right"), - HorzAlign::Center => Some("center"), - HorzAlign::Left => None, - }; - if let Some(horz_align) = horz_align { - write!(&mut style, "text-align: {horz_align}; ").unwrap(); - } - - if cell.rotate { - write!(&mut style, "writing-mode: sideways-lr; ").unwrap(); - } - - let vert_align = match cell.style.cell_style.vert_align { - VertAlign::Top => None, - VertAlign::Middle => Some("middle"), - VertAlign::Bottom => Some("bottom"), - }; - if let Some(vert_align) = vert_align { - write!(&mut style, "vertical-align: {vert_align}; ").unwrap(); - } - let bg = cell.style.font_style.bg[alternate_row as usize]; - if bg != Color::WHITE { - write!(&mut style, "background: {}; ", bg.display_css()).unwrap(); - } - - let fg = cell.style.font_style.fg[alternate_row as usize]; - if fg != Color::BLACK { - write!(&mut style, "color: {}; ", fg.display_css()).unwrap(); - } - - if !cell.style.font_style.font.is_empty() { - write!( - &mut style, - r#"font-family: "{}"; "#, - Escape::new(&cell.style.font_style.font) - ) - .unwrap(); - } - - if cell.style.font_style.bold { - write!(&mut style, "font-weight: bold; ").unwrap(); - } - if cell.style.font_style.italic { - write!(&mut style, "font-style: italic; ").unwrap(); - } - if cell.style.font_style.underline { - write!(&mut style, "text-decoration: underline; ").unwrap(); - } - if cell.style.font_style.size != 0 { - write!(&mut style, "font-size: {}pt; ", cell.style.font_style.size).unwrap(); - } - - if let Some(table) = table { - Self::put_border(&mut style, table.get_rule(Axis2::Y, rect.top_left()), "top"); - Self::put_border( - &mut style, - table.get_rule(Axis2::X, rect.top_left()), - "left", - ); - if rect[Axis2::X].end == table.n[Axis2::X] { - Self::put_border( - &mut style, - table.get_rule( - Axis2::X, - Coord2::new(rect[Axis2::X].end, rect[Axis2::Y].start), - ), - "right", - ); - } - if rect[Axis2::Y].end == table.n[Axis2::Y] { - Self::put_border( - &mut style, - table.get_rule( - Axis2::Y, - Coord2::new(rect[Axis2::X].start, rect[Axis2::Y].end), - ), - "bottom", - ); - } - } - - if !style.is_empty() { - write!( - &mut self.writer, - " style='{}'", - Escape::new(style.trim_end_matches("; ")) - .with_apos("'") - .with_quote("\"") - )?; - } - - let col_span = rect[Axis2::X].len(); - if col_span > 1 { - write!(&mut self.writer, r#" colspan="{col_span}""#)?; - } - - let row_span = rect[Axis2::Y].len(); - if row_span > 1 { - write!(&mut self.writer, r#" rowspan="{row_span}""#)?; - } - - write!(&mut self.writer, ">")?; - - let mut text = SmallString::<[u8; 64]>::new(); - write!(&mut text, "{body}").unwrap(); - write!( - &mut self.writer, - "{}", - Escape::new(&text).with_newline("
") - )?; - - if suffixes.has_subscripts() { - write!(&mut self.writer, "")?; - for (index, subscript) in suffixes.subscripts().enumerate() { - if index > 0 { - write!(&mut self.writer, ",")?; - } - write!( - &mut self.writer, - "{}", - Escape::new(subscript) - .with_space(" ") - .with_newline("
") - )?; - } - write!(&mut self.writer, "
")?; - } - - if suffixes.has_footnotes() { - write!(&mut self.writer, "")?; - for (index, footnote) in suffixes.footnotes().enumerate() { - if index > 0 { - write!(&mut self.writer, ",")?; - } - let mut marker = SmallString::<[u8; 8]>::new(); - write!(&mut marker, "{footnote}").unwrap(); - write!( - &mut self.writer, - "{}", - Escape::new(&marker) - .with_space(" ") - .with_newline("
") - )?; - } - write!(&mut self.writer, "
")?; - } - - writeln!(&mut self.writer, "") - } - - fn put_border(dst: &mut String, style: BorderStyle, border_name: &str) { - if let Some(css_style) = style.stroke.as_css() { - write!(dst, "border-{border_name}: {css_style}").unwrap(); - if style.color != Color::BLACK { - write!(dst, " {}", style.color.display_css()).unwrap(); - } - write!(dst, "; ").unwrap(); - } - } -} - -fn put_header(mut writer: W) -> std::io::Result<()> -where - W: Write, -{ - write!( - &mut writer, - r#" - - -PSPP Output - - -{} -"#, - Escape::new(env!("CARGO_PKG_VERSION")), - HEADER_CSS, - )?; - Ok(()) -} - -const HEADER_CSS: &str = r#" - - -"#; - -impl Driver for HtmlDriver -where - W: Write, -{ - fn name(&self) -> Cow<'static, str> { - Cow::from("html") - } - - fn write(&mut self, item: &Arc) { - match &item.details { - Details::Chart => todo!(), - Details::Image => todo!(), - Details::Group(_) => todo!(), - Details::Message(_diagnostic) => todo!(), - Details::PageBreak => (), - Details::Table(pivot_table) => { - self.render(pivot_table).unwrap(); // XXX - } - Details::Text(_text) => todo!(), - } - } -} - -struct Escape<'a> { - string: &'a str, - space: &'static str, - newline: &'static str, - quote: &'static str, - apos: &'static str, -} - -impl<'a> Escape<'a> { - fn new(string: &'a str) -> Self { - Self { - string, - space: " ", - newline: "\n", - quote: """, - apos: "'", - } - } - fn with_space(self, space: &'static str) -> Self { - Self { space, ..self } - } - fn with_newline(self, newline: &'static str) -> Self { - Self { newline, ..self } - } - fn with_quote(self, quote: &'static str) -> Self { - Self { quote, ..self } - } - fn with_apos(self, apos: &'static str) -> Self { - Self { apos, ..self } - } -} - -impl Display for Escape<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for c in self.string.chars() { - match c { - '\n' => f.write_str(self.newline)?, - ' ' => f.write_str(self.space)?, - '&' => f.write_str("&")?, - '<' => f.write_str("<")?, - '>' => f.write_str(">")?, - '"' => f.write_str(self.quote)?, - '\'' => f.write_str(self.apos)?, - _ => f.write_char(c)?, - } - } - Ok(()) - } -} diff --git a/rust/pspp/src/output/json.rs b/rust/pspp/src/output/json.rs deleted file mode 100644 index c7f52bd5e7..0000000000 --- a/rust/pspp/src/output/json.rs +++ /dev/null @@ -1,58 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use std::{ - borrow::Cow, - fs::File, - io::{BufWriter, Write}, - path::PathBuf, - sync::Arc, -}; - -use serde::{Deserialize, Serialize}; - -use super::{Item, driver::Driver}; - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct JsonConfig { - file: PathBuf, -} - -pub struct JsonDriver { - file: BufWriter, -} - -impl JsonDriver { - pub fn new(config: &JsonConfig) -> std::io::Result { - Ok(Self { - file: BufWriter::new(File::create(&config.file)?), - }) - } -} - -impl Driver for JsonDriver { - fn name(&self) -> Cow<'static, str> { - Cow::from("json") - } - - fn write(&mut self, item: &Arc) { - serde_json::to_writer_pretty(&mut self.file, item).unwrap(); // XXX handle errors - } - - fn flush(&mut self) { - let _ = self.file.flush(); - } -} diff --git a/rust/pspp/src/output/pivot/tests.rs b/rust/pspp/src/output/pivot/tests.rs index 27c3f1975d..d54e865a95 100644 --- a/rust/pspp/src/output/pivot/tests.rs +++ b/rust/pspp/src/output/pivot/tests.rs @@ -20,15 +20,17 @@ use enum_map::EnumMap; use crate::output::{ Details, Item, - cairo::{CairoConfig, CairoDriver}, - driver::Driver, - html::HtmlDriver, + drivers::{ + Driver, + cairo::{CairoConfig, CairoDriver}, + html::HtmlDriver, + spv::SpvDriver, + }, pivot::{ Area, Axis2, Border, BorderStyle, Class, Color, Dimension, Footnote, FootnoteMarkerPosition, FootnoteMarkerType, Footnotes, Group, HeadingRegion, LabelPosition, Look, PivotTable, RowColBorder, Stroke, }, - spv::SpvDriver, }; use super::{Axis3, Value}; diff --git a/rust/pspp/src/output/spv.rs b/rust/pspp/src/output/spv.rs deleted file mode 100644 index 9f7290f1b3..0000000000 --- a/rust/pspp/src/output/spv.rs +++ /dev/null @@ -1,1373 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use core::f64; -use std::{ - borrow::Cow, - fs::File, - io::{Cursor, Seek, Write}, - iter::{repeat, repeat_n}, - path::PathBuf, - sync::Arc, -}; - -use binrw::{BinWrite, Endian}; -use chrono::Utc; -use enum_map::EnumMap; -use quick_xml::{ - ElementWriter, - events::{BytesText, attributes::Attribute}, - writer::Writer as XmlWriter, -}; -use serde::{Deserialize, Serialize}; -use zip::{ZipWriter, result::ZipResult, write::SimpleFileOptions}; - -use crate::{ - format::{Format, Type}, - output::{ - Item, Text, - driver::Driver, - page::{Heading, PageSetup}, - pivot::{ - Area, AreaStyle, Axis2, Axis3, Border, BorderStyle, BoxBorder, Category, CellStyle, - Color, Dimension, FontStyle, Footnote, FootnoteMarkerPosition, FootnoteMarkerType, - Footnotes, Group, HeadingRegion, HorzAlign, LabelPosition, Leaf, PivotTable, - RowColBorder, Stroke, Value, ValueInner, ValueStyle, VertAlign, - }, - }, - settings::Show, - util::ToSmallString, -}; - -fn light_table_name(table_id: u64) -> String { - format!("{table_id:011}_lightTableData.bin") -} - -fn output_viewer_name(heading_id: u64, is_heading: bool) -> String { - format!( - "outputViewer{heading_id:010}{}.xml", - if is_heading { "_heading" } else { "" } - ) -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct SpvConfig { - /// Output file name. - pub file: PathBuf, - - /// Page setup. - pub page_setup: Option, -} - -pub struct SpvDriver -where - W: Write + Seek, -{ - writer: ZipWriter, - needs_page_break: bool, - next_table_id: u64, - next_heading_id: u64, - page_setup: Option, -} - -impl SpvDriver { - pub fn new(config: &SpvConfig) -> std::io::Result { - let mut driver = Self::for_writer(File::create(&config.file)?); - if let Some(page_setup) = &config.page_setup { - driver = driver.with_page_setup(page_setup.clone()); - } - Ok(driver) - } -} - -impl SpvDriver -where - W: Write + Seek, -{ - pub fn for_writer(writer: W) -> Self { - let mut writer = ZipWriter::new(writer); - writer - .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default()) - .unwrap(); - writer.write_all("allowPivoting=true".as_bytes()).unwrap(); - Self { - writer, - needs_page_break: false, - next_table_id: 1, - next_heading_id: 1, - page_setup: None, - } - } - - pub fn with_page_setup(self, page_setup: PageSetup) -> Self { - Self { - page_setup: Some(page_setup), - ..self - } - } - - pub fn close(mut self) -> ZipResult { - self.writer - .start_file("META-INF/MANIFEST.MF", SimpleFileOptions::default())?; - write!(&mut self.writer, "allowPivoting=true")?; - self.writer.finish() - } - - fn page_break_before(&mut self) -> bool { - let page_break_before = self.needs_page_break; - self.needs_page_break = false; - page_break_before - } - - fn write_table( - &mut self, - item: &Item, - pivot_table: &PivotTable, - structure: &mut XmlWriter, - ) where - X: Write, - { - let table_id = self.next_table_id; - self.next_table_id += 1; - - let mut content = Vec::new(); - let mut cursor = Cursor::new(&mut content); - pivot_table.write_le(&mut cursor).unwrap(); - - let table_name = light_table_name(table_id); - self.writer - .start_file(&table_name, SimpleFileOptions::default()) - .unwrap(); // XXX - self.writer.write_all(&content).unwrap(); // XXX - - self.container(structure, item, "vtb:table", |element| { - element - .with_attribute(("tableId", Cow::from(table_id.to_string()))) - .with_attribute(( - "subType", - Cow::from(pivot_table.subtype().display(pivot_table).to_string()), - )) - .write_inner_content(|w| { - w.create_element("vtb:tableStructure") - .write_inner_content(|w| { - w.create_element("vtb:dataPath") - .write_text_content(BytesText::new(&table_name))?; - Ok(()) - })?; - Ok(()) - }) - .unwrap(); - }); - } - - fn write_text(&mut self, item: &Item, text: &Text, structure: &mut XmlWriter) - where - X: Write, - { - self.container(structure, item, "vtx:text", |w| { - w.with_attribute(("type", text.type_.as_xml_str())) - .write_text_content(BytesText::new(&text.content.display(()).to_string())) - .unwrap(); - }); - } - - fn write_item(&mut self, item: &Item, structure: &mut XmlWriter) - where - X: Write, - { - match &item.details { - super::Details::Chart => todo!(), - super::Details::Image => todo!(), - super::Details::Group(children) => { - let mut attributes = Vec::::new(); - if let Some(command_name) = &item.command_name { - attributes.push(("commandName", command_name.as_str()).into()); - } - if !item.show { - attributes.push(("visibility", "collapsed").into()); - } - structure - .create_element("heading") - .with_attributes(attributes) - .write_inner_content(|w| { - w.create_element("label") - .write_text_content(BytesText::new(&item.label()))?; - for child in children { - self.write_item(child, w); - } - Ok(()) - }) - .unwrap(); - } - super::Details::Message(diagnostic) => { - self.write_text(item, &Text::from(diagnostic.as_ref()), structure) - } - super::Details::PageBreak => { - self.needs_page_break = true; - } - super::Details::Table(pivot_table) => self.write_table(item, pivot_table, structure), - super::Details::Text(text) => self.write_text(item, text, structure), - } - } - - fn container( - &mut self, - writer: &mut XmlWriter, - item: &Item, - inner_elem: &str, - closure: F, - ) where - X: Write, - F: FnOnce(ElementWriter), - { - writer - .create_element("container") - .with_attributes( - self.page_break_before() - .then_some(("page-break-before", "always")), - ) - .with_attribute(("visibility", if item.show { "visible" } else { "hidden" })) - .write_inner_content(|w| { - let mut element = w - .create_element("label") - .write_text_content(BytesText::new(&item.label())) - .unwrap() - .create_element(inner_elem); - if let Some(command_name) = &item.command_name { - element = element.with_attribute(("commandName", command_name.as_str())); - }; - closure(element); - Ok(()) - }) - .unwrap(); - } -} - -impl BinWrite for PivotTable { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - _args: (), - ) -> binrw::BinResult<()> { - // Header. - ( - 1u8, - 0u8, - 3u32, // version - SpvBool(true), // x0 - SpvBool(false), // x1 - SpvBool(self.rotate_inner_column_labels), - SpvBool(self.rotate_outer_row_labels), - SpvBool(true), // x2 - 0x15u32, // x3 - *self.look.heading_widths[HeadingRegion::Columns].start() as i32, - *self.look.heading_widths[HeadingRegion::Columns].end() as i32, - *self.look.heading_widths[HeadingRegion::Rows].start() as i32, - *self.look.heading_widths[HeadingRegion::Rows].end() as i32, - 0u64, - ) - .write_le(writer)?; - - // Titles. - ( - self.title(), - self.subtype(), - Optional(Some(self.title())), - Optional(self.corner_text.as_ref()), - Optional(self.caption.as_ref()), - ) - .write_le(writer)?; - - // Footnotes. - self.footnotes.write_le(writer)?; - - // Areas. - static SPV_AREAS: [Area; 8] = [ - Area::Title, - Area::Caption, - Area::Footer, - Area::Corner, - Area::Labels(Axis2::X), - Area::Labels(Axis2::Y), - Area::Data, - Area::Layers, - ]; - for (index, area) in SPV_AREAS.into_iter().enumerate() { - self.look.areas[area].write_le_args(writer, index)?; - } - - // Borders. - static SPV_BORDERS: [Border; 19] = [ - Border::Title, - Border::OuterFrame(BoxBorder::Left), - Border::OuterFrame(BoxBorder::Top), - Border::OuterFrame(BoxBorder::Right), - Border::OuterFrame(BoxBorder::Bottom), - Border::InnerFrame(BoxBorder::Left), - Border::InnerFrame(BoxBorder::Top), - Border::InnerFrame(BoxBorder::Right), - Border::InnerFrame(BoxBorder::Bottom), - Border::DataLeft, - Border::DataTop, - Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::X)), - Border::Dimension(RowColBorder(HeadingRegion::Rows, Axis2::Y)), - Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::X)), - Border::Dimension(RowColBorder(HeadingRegion::Columns, Axis2::Y)), - Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::X)), - Border::Category(RowColBorder(HeadingRegion::Rows, Axis2::Y)), - Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::X)), - Border::Category(RowColBorder(HeadingRegion::Columns, Axis2::Y)), - ]; - let borders_start = Count::new(writer)?; - (1, SPV_BORDERS.len() as u32).write_be(writer)?; - for (index, border) in SPV_BORDERS.into_iter().enumerate() { - self.look.borders[border].write_be_args(writer, index)?; - } - (SpvBool(self.show_grid_lines), 0u8, 0u16).write_le(writer)?; - borders_start.finish_le32(writer)?; - - // Print Settings. - Counted::new(( - 1u32, - SpvBool(self.look.print_all_layers), - SpvBool(self.look.paginate_layers), - SpvBool(self.look.shrink_to_fit[Axis2::X]), - SpvBool(self.look.shrink_to_fit[Axis2::Y]), - SpvBool(self.look.top_continuation), - SpvBool(self.look.bottom_continuation), - self.look.n_orphan_lines as u32, - SpvString(self.look.continuation.as_ref().map_or("", |s| s.as_str())), - )) - .with_endian(Endian::Little) - .write_be(writer)?; - - // Table Settings. - Counted::new(( - 1u32, - 4u32, - self.spv_layer() as u32, - SpvBool(self.look.hide_empty), - SpvBool(self.look.row_label_position == LabelPosition::Corner), - SpvBool(self.look.footnote_marker_type == FootnoteMarkerType::Alphabetic), - SpvBool(self.look.footnote_marker_position == FootnoteMarkerPosition::Superscript), - 0u8, - Counted::new(( - 0u32, // n-row-breaks - 0u32, // n-column-breaks - 0u32, // n-row-keeps - 0u32, // n-column-keeps - 0u32, // n-row-point-keeps - 0u32, // n-column-point-keeps - )), - SpvString::optional(&self.notes), - SpvString::optional(&self.look.name), - Zeros(82), - )) - .with_endian(Endian::Little) - .write_be(writer)?; - - fn y0(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { - ( - pivot_table.settings.epoch.0 as u32, - u8::from(pivot_table.settings.decimal), - b',', - ) - } - - fn custom_currency(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { - ( - 5, - EnumMap::from_fn(|cc| { - SpvString(pivot_table.settings.number_style(Type::CC(cc)).to_string()) - }) - .into_array(), - ) - } - - fn x1(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { - ( - 0u8, // x14 - if pivot_table.show_title { 1u8 } else { 10u8 }, - 0u8, // x16 - 0u8, // lang - Show::as_spv(&pivot_table.show_variables), - Show::as_spv(&pivot_table.show_values), - -1i32, // x18 - -1i32, // x19 - Zeros(17), - SpvBool(false), // x20 - SpvBool(pivot_table.show_caption), - ) - } - - fn x2() -> impl for<'a> BinWrite = ()> { - Counted::new(( - 0u32, // n-row-heights - 0u32, // n-style-maps - 0u32, // n-styles, - 0u32, - )) - } - - fn y1(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> + use<'_> { - ( - SpvString::optional(&pivot_table.command_c), - SpvString::optional(&pivot_table.command_local), - SpvString::optional(&pivot_table.language), - SpvString("UTF-8"), - SpvString::optional(&pivot_table.locale), - SpvBool(false), // x10 - SpvBool(pivot_table.settings.leading_zero), - SpvBool(true), // x12 - SpvBool(true), // x13 - y0(pivot_table), - ) - } - - fn y2(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> { - (custom_currency(pivot_table), b'.', SpvBool(false)) - } - - fn x3(pivot_table: &PivotTable) -> impl for<'a> BinWrite = ()> + use<'_> { - Counted::new(( - 1u8, - 0u8, - 4u8, // x21 - 0u8, - 0u8, - 0u8, - y1(pivot_table), - pivot_table.small, - 1u8, - SpvString::optional(&pivot_table.dataset), - SpvString::optional(&pivot_table.datafile), - 0u32, - pivot_table - .date - .map_or(0i64, |date| date.and_utc().timestamp()), - y2(pivot_table), - )) - } - - // Formats. - ( - 0u32, - SpvString("en_US.ISO_8859-1:1987"), - 0u32, // XXX current_layer - SpvBool(false), // x7 - SpvBool(false), // x8 - SpvBool(false), // x9 - y0(self), - custom_currency(self), - Counted::new((Counted::new((x1(self), x2())), x3(self))), - ) - .write_le(writer)?; - - // Dimensions. - (self.dimensions.len() as u32).write_le(writer)?; - - let x2 = repeat_n(2, self.axes[Axis3::Z].dimensions.len()) - .chain(repeat_n(0, self.axes[Axis3::Y].dimensions.len())) - .chain(repeat(1)); - for ((index, dimension), x2) in self.dimensions.iter().enumerate().zip(x2) { - dimension.write_options(writer, endian, (index, x2))?; - } - - // Axes. - for axis in [Axis3::Z, Axis3::Y, Axis3::X] { - (self.axes[axis].dimensions.len() as u32).write_le(writer)?; - } - for axis in [Axis3::Z, Axis3::Y, Axis3::X] { - for index in self.axes[axis].dimensions.iter().copied() { - (index as u32).write_le(writer)?; - } - } - - // Cells. - (self.cells.len() as u32).write_le(writer)?; - for (index, value) in &self.cells { - (*index as u64, value).write_le(writer)?; - } - - Ok(()) - } -} - -impl PivotTable { - fn spv_layer(&self) -> usize { - let mut layer = 0; - for (dimension, layer_value) in self - .axis_dimensions(Axis3::Z) - .zip(self.current_layer.iter().copied()) - .rev() - { - layer = layer * dimension.len() + layer_value; - } - layer - } -} - -impl Driver for SpvDriver -where - W: Write + Seek, -{ - fn name(&self) -> Cow<'static, str> { - Cow::from("spv") - } - - fn write(&mut self, item: &Arc) { - if item.details.is_page_break() { - self.needs_page_break = true; - return; - } - - let mut headings = XmlWriter::new(Cursor::new(Vec::new())); - let element = headings - .create_element("heading") - .with_attribute(( - "creation-date-time", - Cow::from(Utc::now().format("%x %x").to_string()), - )) - .with_attribute(( - "creator", - Cow::from(format!( - "{} {}", - env!("CARGO_PKG_NAME"), - env!("CARGO_PKG_VERSION") - )), - )) - .with_attribute(("creator-version", "21")) - .with_attribute(("xmlns", "http://xml.spss.com/spss/viewer/viewer-tree")) - .with_attribute(( - "xmlns:vps", - "http://xml.spss.com/spss/viewer/viewer-pagesetup", - )) - .with_attribute(("xmlns:vtx", "http://xml.spss.com/spss/viewer/viewer-text")) - .with_attribute(("xmlns:vtb", "http://xml.spss.com/spss/viewer/viewer-table")); - element - .write_inner_content(|w| { - w.create_element("label") - .write_text_content(BytesText::new("Output"))?; - if let Some(page_setup) = self.page_setup.take() { - write_page_setup(&page_setup, w)?; - } - self.write_item(item, w); - Ok(()) - }) - .unwrap(); - - let headings = headings.into_inner().into_inner(); - let heading_id = self.next_heading_id; - self.next_heading_id += 1; - self.writer - .start_file( - output_viewer_name(heading_id, item.details.as_group().is_some()), - SimpleFileOptions::default(), - ) - .unwrap(); // XXX - self.writer.write_all(&headings).unwrap(); // XXX - } -} - -fn write_page_setup(page_setup: &PageSetup, writer: &mut XmlWriter) -> std::io::Result<()> -where - X: Write, -{ - fn inches<'a>(x: f64) -> Cow<'a, str> { - Cow::from(format!("{x:.2}in")) - } - - writer - .create_element("vps:pageSetup") - .with_attribute(( - "initial-page-number", - Cow::from(format!("{}", page_setup.initial_page_number)), - )) - .with_attribute(( - "chart-size", - match page_setup.chart_size { - super::page::ChartSize::AsIs => "as-is", - super::page::ChartSize::FullHeight => "full-height", - super::page::ChartSize::HalfHeight => "half-height", - super::page::ChartSize::QuarterHeight => "quarter-height", - }, - )) - .with_attribute(("margin-left", inches(page_setup.margins[Axis2::X][0]))) - .with_attribute(("margin-right", inches(page_setup.margins[Axis2::X][1]))) - .with_attribute(("margin-top", inches(page_setup.margins[Axis2::Y][0]))) - .with_attribute(("margin-bottom", inches(page_setup.margins[Axis2::Y][1]))) - .with_attribute(("paper-height", inches(page_setup.paper[Axis2::Y]))) - .with_attribute(("paper-width", inches(page_setup.paper[Axis2::X]))) - .with_attribute(( - "reference-orientation", - match page_setup.orientation { - crate::output::page::Orientation::Portrait => "portrait", - crate::output::page::Orientation::Landscape => "landscape", - }, - )) - .with_attribute(( - "space-after", - Cow::from(format!("{:.1}pt", page_setup.object_spacing * 72.0)), - )) - .write_inner_content(|w| { - write_page_heading(&page_setup.headings[0], "vps:pageHeader", w)?; - write_page_heading(&page_setup.headings[1], "vps:pageFooter", w)?; - Ok(()) - })?; - Ok(()) -} - -fn write_page_heading( - heading: &Heading, - name: &str, - writer: &mut XmlWriter, -) -> std::io::Result<()> -where - X: Write, -{ - let element = writer.create_element(name); - if !heading.0.is_empty() { - element.write_inner_content(|w| { - w.create_element("vps:pageParagraph") - .write_inner_content(|w| { - for paragraph in &heading.0 { - w.create_element("vtx:text") - .with_attribute(("text", "title")) - .write_text_content(BytesText::new(¶graph.markup))?; - } - Ok(()) - })?; - Ok(()) - })?; - } - Ok(()) -} - -fn maybe_with_attribute<'a, 'b, W, I>( - element: ElementWriter<'a, W>, - attr: Option, -) -> ElementWriter<'a, W> -where - I: Into>, -{ - if let Some(attr) = attr { - element.with_attribute(attr) - } else { - element - } -} - -impl BinWrite for Dimension { - type Args<'a> = (usize, u8); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - (index, x2): (usize, u8), - ) -> binrw::BinResult<()> { - ( - &self.root.name, - 0u8, // x1 - x2, - 2u32, // x3 - SpvBool(!self.root.show_label), - SpvBool(self.hide_all_labels), - SpvBool(true), - index as u32, - self.root.children.len() as u32, - ) - .write_options(writer, endian, ())?; - - let mut data_indexes = self.presentation_order.iter().copied(); - for child in &self.root.children { - child.write_le(writer, &mut data_indexes)?; - } - Ok(()) - } -} - -impl Category { - fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> - where - W: Write + Seek, - D: Iterator, - { - match self { - Category::Group(group) => group.write_le(writer, data_indexes), - Category::Leaf(leaf) => leaf.write_le(writer, data_indexes), - } - } -} - -impl Leaf { - fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> - where - W: Write + Seek, - D: Iterator, - { - ( - self.name(), - 0u8, - 0u8, - 0u8, - 2u32, - data_indexes.next().unwrap() as u32, - 0u32, - ) - .write_le(writer) - } -} - -impl Group { - fn write_le(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()> - where - W: Write + Seek, - D: Iterator, - { - ( - self.name(), - 0u8, - 0u8, - 1u8, - 0u32, // x23 - -1i32, - ) - .write_le(writer)?; - - for child in &self.children { - child.write_le(writer, data_indexes)?; - } - Ok(()) - } -} - -impl BinWrite for Footnote { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - ( - &self.content, - Optional(self.marker.as_ref()), - if self.show { 1i32 } else { -1 }, - ) - .write_options(writer, endian, args) - } -} - -impl BinWrite for Footnotes { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - (self.0.len() as u32).write_options(writer, endian, args)?; - for footnote in &self.0 { - footnote.write_options(writer, endian, args)?; - } - Ok(()) - } -} - -impl BinWrite for AreaStyle { - type Args<'a> = usize; - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - index: usize, - ) -> binrw::BinResult<()> { - let typeface = if self.font_style.font.is_empty() { - "SansSerif" - } else { - self.font_style.font.as_str() - }; - ( - (index + 1) as u8, - 0x31u8, - SpvString(typeface), - self.font_style.size as f32 * 1.33, - self.font_style.bold as u32 + 2 * self.font_style.italic as u32, - SpvBool(self.font_style.underline), - self.cell_style - .horz_align - .map_or(64173, |horz_align| horz_align.as_spv(61453)), - self.cell_style.vert_align.as_spv(), - self.font_style.fg[0], - self.font_style.bg[0], - ) - .write_options(writer, endian, ())?; - - if self.font_style.fg[0] != self.font_style.fg[1] - || self.font_style.bg[0] != self.font_style.bg[1] - { - (SpvBool(true), self.font_style.fg[1], self.font_style.bg[1]).write_options( - writer, - endian, - (), - )?; - } else { - (SpvBool(false), SpvString(""), SpvString("")).write_options(writer, endian, ())?; - } - - ( - self.cell_style.margins[Axis2::X][0], - self.cell_style.margins[Axis2::X][1], - self.cell_style.margins[Axis2::Y][0], - self.cell_style.margins[Axis2::Y][1], - ) - .write_options(writer, endian, ()) - } -} - -impl Stroke { - fn as_spv(&self) -> u32 { - match self { - Stroke::None => 0, - Stroke::Solid => 1, - Stroke::Dashed => 2, - Stroke::Thick => 3, - Stroke::Thin => 4, - Stroke::Double => 5, - } - } -} - -impl Color { - fn as_spv(&self) -> u32 { - ((self.alpha as u32) << 24) - | ((self.r as u32) << 16) - | ((self.g as u32) << 8) - | (self.b as u32) - } -} - -impl BinWrite for BorderStyle { - type Args<'a> = usize; - - fn write_options( - &self, - writer: &mut W, - _endian: Endian, - index: usize, - ) -> binrw::BinResult<()> { - (index as u32, self.stroke.as_spv(), self.color.as_spv()).write_be(writer) - } -} - -struct SpvBool(bool); -impl BinWrite for SpvBool { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - (self.0 as u8).write_options(writer, endian, args) - } -} - -struct SpvString(T); -impl<'a> SpvString<&'a str> { - fn optional(s: &'a Option) -> Self { - Self(s.as_ref().map_or("", |s| s.as_str())) - } -} -impl BinWrite for SpvString -where - T: AsRef, -{ - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - let s = self.0.as_ref(); - let length = s.len() as u32; - (length, s.as_bytes()).write_options(writer, endian, args) - } -} - -impl Show { - fn as_spv(this: &Option) -> u8 { - match this { - None => 0, - Some(Show::Value) => 1, - Some(Show::Label) => 2, - Some(Show::Both) => 3, - } - } -} - -struct Count(u64); - -impl Count { - fn new(writer: &mut W) -> binrw::BinResult - where - W: Write + Seek, - { - 0u32.write_le(writer)?; - Ok(Self(writer.stream_position()?)) - } - - fn finish(self, writer: &mut W, endian: Endian) -> binrw::BinResult<()> - where - W: Write + Seek, - { - let saved_position = writer.stream_position()?; - let n_bytes = saved_position - self.0; - writer.seek(std::io::SeekFrom::Start(self.0 - 4))?; - (n_bytes as u32).write_options(writer, endian, ())?; - writer.seek(std::io::SeekFrom::Start(saved_position))?; - Ok(()) - } - - fn finish_le32(self, writer: &mut W) -> binrw::BinResult<()> - where - W: Write + Seek, - { - self.finish(writer, Endian::Little) - } - - fn finish_be32(self, writer: &mut W) -> binrw::BinResult<()> - where - W: Write + Seek, - { - self.finish(writer, Endian::Big) - } -} - -struct Counted { - inner: T, - endian: Option, -} - -impl Counted { - fn new(inner: T) -> Self { - Self { - inner, - endian: None, - } - } - fn with_endian(self, endian: Endian) -> Self { - Self { - inner: self.inner, - endian: Some(endian), - } - } -} - -impl BinWrite for Counted -where - T: BinWrite, - for<'a> T: BinWrite = ()>, -{ - type Args<'a> = T::Args<'a>; - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - let start = Count::new(writer)?; - self.inner.write_options(writer, endian, args)?; - start.finish(writer, self.endian.unwrap_or(endian)) - } -} - -pub struct Zeros(pub usize); - -impl BinWrite for Zeros { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - _endian: Endian, - _args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - for _ in 0..self.0 { - writer.write_all(&[0u8])?; - } - Ok(()) - } -} - -#[derive(Default)] -struct StylePair<'a> { - font_style: Option<&'a FontStyle>, - cell_style: Option<&'a CellStyle>, -} - -impl BinWrite for Color { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - SpvString(&self.without_alpha().display_css().to_small_string::<16>()) - .write_options(writer, endian, args) - } -} - -impl BinWrite for FontStyle { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - let typeface = if self.font.is_empty() { - "SansSerif" - } else { - self.font.as_str() - }; - ( - SpvBool(self.bold), - SpvBool(self.italic), - SpvBool(self.underline), - SpvBool(true), - self.fg[0], - self.bg[0], - SpvString(typeface), - (self.size as f64 * 1.33).ceil() as u8, - ) - .write_options(writer, endian, args) - } -} - -impl HorzAlign { - fn as_spv(&self, decimal: u32) -> u32 { - match self { - HorzAlign::Right => 4, - HorzAlign::Left => 2, - HorzAlign::Center => 0, - HorzAlign::Decimal { .. } => decimal, - } - } - - fn decimal_offset(&self) -> Option { - match *self { - HorzAlign::Decimal { offset, .. } => Some(offset), - _ => None, - } - } -} - -impl VertAlign { - fn as_spv(&self) -> u32 { - match self { - VertAlign::Top => 1, - VertAlign::Middle => 0, - VertAlign::Bottom => 3, - } - } -} - -impl BinWrite for CellStyle { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - ( - self.horz_align - .map_or(0xffffffad, |horz_align| horz_align.as_spv(6)), - self.vert_align.as_spv(), - self.horz_align - .map(|horz_align| horz_align.decimal_offset()) - .unwrap_or_default(), - u16::try_from(self.margins[Axis2::X][0]).unwrap_or_default(), - u16::try_from(self.margins[Axis2::X][1]).unwrap_or_default(), - u16::try_from(self.margins[Axis2::Y][0]).unwrap_or_default(), - u16::try_from(self.margins[Axis2::Y][1]).unwrap_or_default(), - ) - .write_options(writer, endian, args) - } -} - -impl<'a> BinWrite for StylePair<'a> { - type Args<'b> = (); - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - ( - Optional(self.font_style.as_ref()), - Optional(self.cell_style.as_ref()), - ) - .write_options(writer, endian, args) - } -} - -struct Optional(Option); - -impl BinWrite for Optional -where - T: BinWrite, -{ - type Args<'a> = T::Args<'a>; - - fn write_options( - &self, - writer: &mut W, - endian: Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - match &self.0 { - Some(value) => { - 0x31u8.write_le(writer)?; - value.write_options(writer, endian, args) - } - None => 0x58u8.write_le(writer), - } - } -} - -struct ValueMod<'a> { - style: &'a Option>, - template: Option<&'a str>, -} - -impl<'a> ValueMod<'a> { - fn new(value: &'a Value) -> Self { - Self { - style: &value.styling, - template: None, - } - } -} - -impl<'a> Default for ValueMod<'a> { - fn default() -> Self { - Self { - style: &None, - template: None, - } - } -} - -impl<'a> BinWrite for ValueMod<'a> { - type Args<'b> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - if self.style.as_ref().is_some_and(|style| !style.is_empty()) || self.template.is_some() { - 0x31u8.write_options(writer, endian, args)?; - let default_style = Default::default(); - let style = self.style.as_ref().unwrap_or(&default_style); - - (style.footnotes.len() as u32).write_options(writer, endian, args)?; - for footnote in &style.footnotes { - (footnote.index() as u16).write_options(writer, endian, args)?; - } - - (style.subscripts.len() as u32).write_options(writer, endian, args)?; - for subscript in &style.subscripts { - SpvString(subscript.as_str()).write_options(writer, endian, args)?; - } - let v3_start = Count::new(writer)?; - let template_string_start = Count::new(writer)?; - if let Some(template) = self.template { - Count::new(writer)?.finish_le32(writer)?; - (0x31u8, SpvString(template)).write_options(writer, endian, args)?; - } - template_string_start.finish_le32(writer)?; - style - .style - .as_ref() - .map_or_else(StylePair::default, |area_style| StylePair { - font_style: Some(&area_style.font_style), - cell_style: Some(&area_style.cell_style), - }) - .write_options(writer, endian, args)?; - v3_start.finish_le32(writer) - } else { - 0x58u8.write_options(writer, endian, args) - } - } -} - -struct SpvFormat { - format: Format, - honor_small: bool, -} - -impl BinWrite for SpvFormat { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - let type_ = if self.format.type_() == Type::F && self.honor_small { - 40 - } else { - self.format.type_().into() - }; - (((type_ as u32) << 16) | ((self.format.w() as u32) << 8) | (self.format.d() as u32)) - .write_options(writer, endian, args) - } -} - -impl BinWrite for Value { - type Args<'a> = (); - - fn write_options( - &self, - writer: &mut W, - endian: binrw::Endian, - args: Self::Args<'_>, - ) -> binrw::BinResult<()> { - match &self.inner { - ValueInner::Number(number) => { - let format = SpvFormat { - format: number.format, - honor_small: number.honor_small, - }; - if number.variable.is_some() || number.value_label.is_some() { - ( - 2u8, - ValueMod::new(self), - format, - number.value.unwrap_or(f64::MIN), - SpvString::optional(&number.variable), - SpvString::optional(&number.value_label), - Show::as_spv(&number.show), - ) - .write_options(writer, endian, args)?; - } else { - ( - 1u8, - ValueMod::new(self), - format, - number.value.unwrap_or(f64::MIN), - ) - .write_options(writer, endian, args)?; - } - } - ValueInner::String(string) => { - ( - 4u8, - ValueMod::new(self), - SpvFormat { - format: if string.hex { - Format::new(Type::AHex, (string.s.len() * 2) as u16, 0).unwrap() - } else { - Format::new(Type::A, (string.s.len()) as u16, 0).unwrap() - }, - honor_small: false, - }, - SpvString::optional(&string.value_label), - SpvString::optional(&string.var_name), - Show::as_spv(&string.show), - SpvString(&string.s), - ) - .write_options(writer, endian, args)?; - } - ValueInner::Variable(variable) => { - ( - 5u8, - ValueMod::new(self), - SpvString(&variable.var_name), - SpvString::optional(&variable.variable_label), - Show::as_spv(&variable.show), - ) - .write_options(writer, endian, args)?; - } - ValueInner::Text(text) => { - ( - 3u8, - SpvString(&text.localized), - ValueMod::new(self), - SpvString(text.id()), - SpvString(text.c()), - SpvBool(true), - ) - .write_options(writer, endian, args)?; - } - ValueInner::Template(template) => { - ( - 0u8, - ValueMod::new(self), - SpvString(&template.localized), - template.args.len() as u32, - ) - .write_options(writer, endian, args)?; - for arg in &template.args { - if arg.len() > 1 { - (arg.len() as u32, 0u32).write_options(writer, endian, args)?; - for (index, value) in arg.iter().enumerate() { - if index > 0 { - 0u32.write_le(writer)?; - } - value.write_options(writer, endian, args)?; - } - } else { - (0u32, arg).write_options(writer, endian, args)?; - } - } - } - ValueInner::Empty => { - ( - 3u8, - SpvString(""), - ValueMod::default(), - SpvString(""), - SpvString(""), - SpvBool(true), - ) - .write_options(writer, endian, args)?; - } - } - Ok(()) - } -} diff --git a/rust/pspp/src/output/text.rs b/rust/pspp/src/output/text.rs deleted file mode 100644 index 990f1fae7a..0000000000 --- a/rust/pspp/src/output/text.rs +++ /dev/null @@ -1,707 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use std::{ - borrow::Cow, - fmt::{Display, Error as FmtError, Result as FmtResult, Write as FmtWrite}, - fs::File, - io::{BufWriter, Write as IoWrite}, - ops::{Index, Range}, - path::PathBuf, - sync::{Arc, LazyLock}, -}; - -use enum_map::{Enum, EnumMap, enum_map}; -use serde::{Deserialize, Serialize}; -use unicode_linebreak::{BreakOpportunity, linebreaks}; -use unicode_width::UnicodeWidthStr; - -use crate::output::{render::Extreme, table::DrawCell, text_line::Emphasis}; - -use super::{ - Details, Item, - driver::Driver, - pivot::{Axis2, BorderStyle, Coord2, HorzAlign, PivotTable, Rect2, Stroke}, - render::{Device, Pager, Params}, - table::Content, - text_line::{TextLine, clip_text}, -}; - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum Boxes { - Ascii, - #[default] - Unicode, -} - -impl Boxes { - fn box_chars(&self) -> &'static BoxChars { - match self { - Boxes::Ascii => &ASCII_BOX, - Boxes::Unicode => &UNICODE_BOX, - } - } -} - -#[derive(Clone, Debug, Deserialize, Serialize)] -pub struct TextConfig { - /// Output file name. - file: Option, - - /// Renderer config. - #[serde(flatten)] - options: TextRendererOptions, -} - -#[derive(Clone, Debug, Default, Deserialize, Serialize)] -#[serde(default)] -pub struct TextRendererOptions { - /// Enable bold and underline in output? - pub emphasis: bool, - - /// Page width. - pub width: Option, - - /// ASCII or Unicode - pub boxes: Boxes, -} - -pub struct TextRenderer { - /// Enable bold and underline in output? - emphasis: bool, - - /// Page width. - width: usize, - - /// Minimum cell size to break across pages. - min_hbreak: usize, - - box_chars: &'static BoxChars, - - params: Params, - n_objects: usize, - lines: Vec, -} - -impl Default for TextRenderer { - fn default() -> Self { - Self::new(&TextRendererOptions::default()) - } -} - -impl TextRenderer { - pub fn new(config: &TextRendererOptions) -> Self { - let width = config.width.unwrap_or(usize::MAX); - Self { - emphasis: config.emphasis, - width, - min_hbreak: 20, - box_chars: config.boxes.box_chars(), - n_objects: 0, - params: Params { - size: Coord2::new(width, usize::MAX), - font_size: EnumMap::from_fn(|_| 1), - line_widths: EnumMap::from_fn(|stroke| if stroke == Stroke::None { 0 } else { 1 }), - px_size: None, - min_break: EnumMap::default(), - supports_margins: false, - rtl: false, - printing: true, - can_adjust_break: false, - can_scale: false, - }, - lines: Vec::new(), - } - } -} - -#[derive(Copy, Clone, PartialEq, Eq, Enum)] -enum Line { - None, - Dashed, - Single, - Double, -} - -impl From for Line { - fn from(stroke: Stroke) -> Self { - match stroke { - Stroke::None => Self::None, - Stroke::Solid | Stroke::Thick | Stroke::Thin => Self::Single, - Stroke::Dashed => Self::Dashed, - Stroke::Double => Self::Double, - } - } -} - -#[derive(Copy, Clone, PartialEq, Eq, Enum)] -struct Lines { - r: Line, - b: Line, - l: Line, - t: Line, -} - -#[derive(Default)] -struct BoxChars(EnumMap); - -impl BoxChars { - fn put(&mut self, r: Line, b: Line, l: Line, chars: [char; 4]) { - use Line::*; - for (t, c) in [None, Dashed, Single, Double] - .into_iter() - .zip(chars.into_iter()) - { - self.0[Lines { r, b, l, t }] = c; - } - } -} - -impl Index for BoxChars { - type Output = char; - - fn index(&self, lines: Lines) -> &Self::Output { - &self.0[lines] - } -} - -static ASCII_BOX: LazyLock = LazyLock::new(|| { - let mut ascii_box = BoxChars::default(); - let n = Line::None; - let d = Line::Dashed; - use Line::{Double as D, Single as S}; - ascii_box.put(n, n, n, [' ', '|', '|', '#']); - ascii_box.put(n, n, d, ['-', '+', '+', '#']); - ascii_box.put(n, n, S, ['-', '+', '+', '#']); - ascii_box.put(n, n, D, ['=', '#', '#', '#']); - ascii_box.put(n, d, n, ['|', '|', '|', '#']); - ascii_box.put(n, d, d, ['+', '+', '+', '#']); - ascii_box.put(n, d, S, ['+', '+', '+', '#']); - ascii_box.put(n, d, D, ['#', '#', '#', '#']); - ascii_box.put(n, S, n, ['|', '|', '|', '#']); - ascii_box.put(n, S, d, ['+', '+', '+', '#']); - ascii_box.put(n, S, S, ['+', '+', '+', '#']); - ascii_box.put(n, S, D, ['#', '#', '#', '#']); - ascii_box.put(n, D, n, ['#', '#', '#', '#']); - ascii_box.put(n, D, d, ['#', '#', '#', '#']); - ascii_box.put(n, D, S, ['#', '#', '#', '#']); - ascii_box.put(n, D, D, ['#', '#', '#', '#']); - ascii_box.put(d, n, n, ['-', '+', '+', '#']); - ascii_box.put(d, n, d, ['-', '+', '+', '#']); - ascii_box.put(d, n, S, ['-', '+', '+', '#']); - ascii_box.put(d, n, D, ['#', '#', '#', '#']); - ascii_box.put(d, d, n, ['+', '+', '+', '#']); - ascii_box.put(d, d, d, ['+', '+', '+', '#']); - ascii_box.put(d, d, S, ['+', '+', '+', '#']); - ascii_box.put(d, d, D, ['#', '#', '#', '#']); - ascii_box.put(d, S, n, ['+', '+', '+', '#']); - ascii_box.put(d, S, d, ['+', '+', '+', '#']); - ascii_box.put(d, S, S, ['+', '+', '+', '#']); - ascii_box.put(d, S, D, ['#', '#', '#', '#']); - ascii_box.put(d, D, n, ['#', '#', '#', '#']); - ascii_box.put(d, D, d, ['#', '#', '#', '#']); - ascii_box.put(d, D, S, ['#', '#', '#', '#']); - ascii_box.put(d, D, D, ['#', '#', '#', '#']); - ascii_box.put(S, n, n, ['-', '+', '+', '#']); - ascii_box.put(S, n, d, ['-', '+', '+', '#']); - ascii_box.put(S, n, S, ['-', '+', '+', '#']); - ascii_box.put(S, n, D, ['#', '#', '#', '#']); - ascii_box.put(S, d, n, ['+', '+', '+', '#']); - ascii_box.put(S, d, d, ['+', '+', '+', '#']); - ascii_box.put(S, d, S, ['+', '+', '+', '#']); - ascii_box.put(S, d, D, ['#', '#', '#', '#']); - ascii_box.put(S, S, n, ['+', '+', '+', '#']); - ascii_box.put(S, S, d, ['+', '+', '+', '#']); - ascii_box.put(S, S, S, ['+', '+', '+', '#']); - ascii_box.put(S, S, D, ['#', '#', '#', '#']); - ascii_box.put(S, D, n, ['#', '#', '#', '#']); - ascii_box.put(S, D, d, ['#', '#', '#', '#']); - ascii_box.put(S, D, S, ['#', '#', '#', '#']); - ascii_box.put(S, D, D, ['#', '#', '#', '#']); - ascii_box.put(D, n, n, ['=', '#', '#', '#']); - ascii_box.put(D, n, d, ['#', '#', '#', '#']); - ascii_box.put(D, n, S, ['#', '#', '#', '#']); - ascii_box.put(D, n, D, ['=', '#', '#', '#']); - ascii_box.put(D, d, n, ['#', '#', '#', '#']); - ascii_box.put(D, d, d, ['#', '#', '#', '#']); - ascii_box.put(D, d, S, ['#', '#', '#', '#']); - ascii_box.put(D, d, D, ['#', '#', '#', '#']); - ascii_box.put(D, S, n, ['#', '#', '#', '#']); - ascii_box.put(D, S, d, ['#', '#', '#', '#']); - ascii_box.put(D, S, S, ['#', '#', '#', '#']); - ascii_box.put(D, S, D, ['#', '#', '#', '#']); - ascii_box.put(D, D, n, ['#', '#', '#', '#']); - ascii_box.put(D, D, d, ['#', '#', '#', '#']); - ascii_box.put(D, D, S, ['#', '#', '#', '#']); - ascii_box.put(D, D, D, ['#', '#', '#', '#']); - ascii_box -}); - -static UNICODE_BOX: LazyLock = LazyLock::new(|| { - let mut unicode_box = BoxChars::default(); - let n = Line::None; - let d = Line::Dashed; - use Line::{Double as D, Single as S}; - unicode_box.put(n, n, n, [' ', '╵', '╵', '║']); - unicode_box.put(n, n, d, ['╌', '╯', '╯', '╜']); - unicode_box.put(n, n, S, ['╴', '╯', '╯', '╜']); - unicode_box.put(n, n, D, ['═', '╛', '╛', '╝']); - unicode_box.put(n, S, n, ['╷', '│', '│', '║']); - unicode_box.put(n, S, d, ['╮', '┤', '┤', '╢']); - unicode_box.put(n, S, S, ['╮', '┤', '┤', '╢']); - unicode_box.put(n, S, D, ['╕', '╡', '╡', '╣']); - unicode_box.put(n, d, n, ['╷', '┊', '│', '║']); - unicode_box.put(n, d, d, ['╮', '┤', '┤', '╢']); - unicode_box.put(n, d, S, ['╮', '┤', '┤', '╢']); - unicode_box.put(n, d, D, ['╕', '╡', '╡', '╣']); - unicode_box.put(n, D, n, ['║', '║', '║', '║']); - unicode_box.put(n, D, d, ['╖', '╢', '╢', '╢']); - unicode_box.put(n, D, S, ['╖', '╢', '╢', '╢']); - unicode_box.put(n, D, D, ['╗', '╣', '╣', '╣']); - unicode_box.put(d, n, n, ['╌', '╰', '╰', '╙']); - unicode_box.put(d, n, d, ['╌', '┴', '┴', '╨']); - unicode_box.put(d, n, S, ['─', '┴', '┴', '╨']); - unicode_box.put(d, n, D, ['═', '╧', '╧', '╩']); - unicode_box.put(d, d, n, ['╭', '├', '├', '╟']); - unicode_box.put(d, d, d, ['┬', '+', '┼', '╪']); - unicode_box.put(d, d, S, ['┬', '┼', '┼', '╪']); - unicode_box.put(d, d, D, ['╤', '╪', '╪', '╬']); - unicode_box.put(d, S, n, ['╭', '├', '├', '╟']); - unicode_box.put(d, S, d, ['┬', '┼', '┼', '╪']); - unicode_box.put(d, S, S, ['┬', '┼', '┼', '╪']); - unicode_box.put(d, S, D, ['╤', '╪', '╪', '╬']); - unicode_box.put(d, D, n, ['╓', '╟', '╟', '╟']); - unicode_box.put(d, D, d, ['╥', '╫', '╫', '╫']); - unicode_box.put(d, D, S, ['╥', '╫', '╫', '╫']); - unicode_box.put(d, D, D, ['╦', '╬', '╬', '╬']); - unicode_box.put(S, n, n, ['╶', '╰', '╰', '╙']); - unicode_box.put(S, n, d, ['─', '┴', '┴', '╨']); - unicode_box.put(S, n, S, ['─', '┴', '┴', '╨']); - unicode_box.put(S, n, D, ['═', '╧', '╧', '╩']); - unicode_box.put(S, d, n, ['╭', '├', '├', '╟']); - unicode_box.put(S, d, d, ['┬', '┼', '┼', '╪']); - unicode_box.put(S, d, S, ['┬', '┼', '┼', '╪']); - unicode_box.put(S, d, D, ['╤', '╪', '╪', '╬']); - unicode_box.put(S, S, n, ['╭', '├', '├', '╟']); - unicode_box.put(S, S, d, ['┬', '┼', '┼', '╪']); - unicode_box.put(S, S, S, ['┬', '┼', '┼', '╪']); - unicode_box.put(S, S, D, ['╤', '╪', '╪', '╬']); - unicode_box.put(S, D, n, ['╓', '╟', '╟', '╟']); - unicode_box.put(S, D, d, ['╥', '╫', '╫', '╫']); - unicode_box.put(S, D, S, ['╥', '╫', '╫', '╫']); - unicode_box.put(S, D, D, ['╦', '╬', '╬', '╬']); - unicode_box.put(D, n, n, ['═', '╘', '╘', '╚']); - unicode_box.put(D, n, d, ['═', '╧', '╧', '╩']); - unicode_box.put(D, n, S, ['═', '╧', '╧', '╩']); - unicode_box.put(D, n, D, ['═', '╧', '╧', '╩']); - unicode_box.put(D, d, n, ['╒', '╞', '╞', '╠']); - unicode_box.put(D, d, d, ['╤', '╪', '╪', '╬']); - unicode_box.put(D, d, S, ['╤', '╪', '╪', '╬']); - unicode_box.put(D, d, D, ['╤', '╪', '╪', '╬']); - unicode_box.put(D, S, n, ['╒', '╞', '╞', '╠']); - unicode_box.put(D, S, d, ['╤', '╪', '╪', '╬']); - unicode_box.put(D, S, S, ['╤', '╪', '╪', '╬']); - unicode_box.put(D, S, D, ['╤', '╪', '╪', '╬']); - unicode_box.put(D, D, n, ['╔', '╠', '╠', '╠']); - unicode_box.put(D, D, d, ['╠', '╬', '╬', '╬']); - unicode_box.put(D, D, S, ['╠', '╬', '╬', '╬']); - unicode_box.put(D, D, D, ['╦', '╬', '╬', '╬']); - unicode_box -}); - -impl PivotTable { - pub fn display(&self) -> DisplayPivotTable<'_> { - DisplayPivotTable::new(self) - } -} - -impl Display for PivotTable { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.display()) - } -} - -pub struct DisplayPivotTable<'a> { - pt: &'a PivotTable, -} - -impl<'a> DisplayPivotTable<'a> { - fn new(pt: &'a PivotTable) -> Self { - Self { pt } - } -} - -impl Display for DisplayPivotTable<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - TextRenderer::default().render_table(self.pt, f) - } -} - -impl Display for Item { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - TextRenderer::default().render(self, f) - } -} - -pub struct TextDriver { - file: BufWriter, - renderer: TextRenderer, -} - -impl TextDriver { - pub fn new(config: &TextConfig) -> std::io::Result { - Ok(Self { - file: BufWriter::new(match &config.file { - Some(file) => File::create(file)?, - None => File::options().write(true).open("/dev/stdout")?, - }), - renderer: TextRenderer::new(&config.options), - }) - } -} - -impl TextRenderer { - fn render(&mut self, item: &Item, writer: &mut W) -> FmtResult - where - W: FmtWrite, - { - match &item.details { - Details::Chart => todo!(), - Details::Image => todo!(), - Details::Group(children) => { - for (index, child) in children.iter().enumerate() { - if index > 0 { - writeln!(writer)?; - } - self.render(child, writer)?; - } - Ok(()) - } - Details::Message(_diagnostic) => todo!(), - Details::PageBreak => Ok(()), - Details::Table(pivot_table) => self.render_table(pivot_table, writer), - Details::Text(text) => self.render_table(&PivotTable::from((**text).clone()), writer), - } - } - - fn render_table(&mut self, table: &PivotTable, writer: &mut W) -> FmtResult - where - W: FmtWrite, - { - for (index, layer_indexes) in table.layers(true).enumerate() { - if index > 0 { - writeln!(writer)?; - } - - let mut pager = Pager::new(self, table, Some(layer_indexes.as_slice())); - while pager.has_next(self) { - pager.draw_next(self, usize::MAX); - for line in self.lines.drain(..) { - writeln!(writer, "{line}")?; - } - } - } - Ok(()) - } - - fn layout_cell(&self, text: &str, bb: Rect2) -> Coord2 { - if text.is_empty() { - return Coord2::default(); - } - - use Axis2::*; - let breaks = new_line_breaks(text, bb[X].len()); - let mut size = Coord2::new(0, 0); - for text in breaks.take(bb[Y].len()) { - let width = text.width(); - if width > size[X] { - size[X] = width; - } - size[Y] += 1; - } - size - } - - fn get_line(&mut self, y: usize) -> &mut TextLine { - if y >= self.lines.len() { - self.lines.resize(y + 1, TextLine::new()); - } - &mut self.lines[y] - } -} - -struct LineBreaks<'a, B> -where - B: Iterator + Clone + 'a, -{ - text: &'a str, - max_width: usize, - indexes: Range, - width: usize, - saved: Option<(usize, BreakOpportunity)>, - breaks: B, - trailing_newlines: usize, -} - -impl<'a, B> Iterator for LineBreaks<'a, B> -where - B: Iterator + Clone + 'a, -{ - type Item = &'a str; - - fn next(&mut self) -> Option { - while let Some((postindex, opportunity)) = self.saved.take().or_else(|| self.breaks.next()) - { - let index = if postindex != self.text.len() { - self.text[..postindex].char_indices().next_back().unwrap().0 - } else { - postindex - }; - if index <= self.indexes.end { - continue; - } - - let segment_width = self.text[self.indexes.end..index].width(); - if self.width == 0 || self.width + segment_width <= self.max_width { - // Add this segment to the current line. - self.width += segment_width; - self.indexes.end = index; - - // If this was a new-line, we're done. - if opportunity == BreakOpportunity::Mandatory { - let segment = self.text[self.indexes.clone()].trim_end_matches('\n'); - self.indexes = postindex..postindex; - self.width = 0; - return Some(segment); - } - } else { - // Won't fit. Return what we've got and save this segment for next time. - // - // We trim trailing spaces from the line we return, and leading - // spaces from the position where we resume. - let segment = self.text[self.indexes.clone()].trim_end(); - - let start = self.text[self.indexes.end..].trim_start_matches([' ', '\t']); - let start_index = self.text.len() - start.len(); - self.indexes = start_index..start_index; - self.width = 0; - self.saved = Some((postindex, opportunity)); - return Some(segment); - } - } - if self.trailing_newlines > 1 { - self.trailing_newlines -= 1; - Some("") - } else { - None - } - } -} - -fn new_line_breaks( - text: &str, - width: usize, -) -> LineBreaks<'_, impl Iterator + Clone + '_> { - // Trim trailing new-lines from the text, because the linebreaking algorithm - // treats them as if they have width. That is, if you break `"a b c\na b - // c\n"` with a 5-character width, then you end up with: - // - // ```text - // a b c - // a b - // c - // ``` - // - // So, we trim trailing new-lines and then add in extra blank lines at the - // end if necessary. - // - // (The linebreaking algorithm treats new-lines in the middle of the text in - // a normal way, though.) - let trimmed = text.trim_end_matches('\n'); - LineBreaks { - text: trimmed, - max_width: width, - indexes: 0..0, - width: 0, - saved: None, - breaks: linebreaks(trimmed), - trailing_newlines: text.len() - trimmed.len(), - } -} - -impl Driver for TextDriver { - fn name(&self) -> Cow<'static, str> { - Cow::from("text") - } - - fn write(&mut self, item: &Arc) { - let _ = self.renderer.render(item, &mut FmtAdapter(&mut self.file)); - } -} - -struct FmtAdapter(W); - -impl FmtWrite for FmtAdapter -where - W: IoWrite, -{ - fn write_str(&mut self, s: &str) -> FmtResult { - self.0.write_all(s.as_bytes()).map_err(|_| FmtError) - } -} - -impl Device for TextRenderer { - fn params(&self) -> &Params { - &self.params - } - - fn measure_cell_width(&self, cell: &DrawCell) -> EnumMap { - let text = cell.display().to_string(); - enum_map![ - Extreme::Min => self.layout_cell(&text, Rect2::new(0..1, 0..usize::MAX)).x(), - Extreme::Max => self.layout_cell(&text, Rect2::new(0..usize::MAX, 0..usize::MAX)).x(), - ] - } - - fn measure_cell_height(&self, cell: &DrawCell, width: usize) -> usize { - let text = cell.display().to_string(); - self.layout_cell(&text, Rect2::new(0..width, 0..usize::MAX)) - .y() - } - - fn adjust_break(&self, _cell: &Content, _size: Coord2) -> usize { - unreachable!() - } - - fn draw_line(&mut self, bb: Rect2, styles: EnumMap) { - use Axis2::*; - let x = bb[X].start.max(0)..bb[X].end.min(self.width); - let y = bb[Y].start.max(0)..bb[Y].end; - if x.is_empty() || x.end >= self.width { - return; - } - - let lines = Lines { - l: styles[Y][0].stroke.into(), - r: styles[Y][1].stroke.into(), - t: styles[X][0].stroke.into(), - b: styles[X][1].stroke.into(), - }; - let c = self.box_chars[lines]; - for y in y { - self.get_line(y).put_multiple(x.start, c, x.len()); - } - } - - fn draw_cell( - &mut self, - cell: &DrawCell, - _alternate_row: bool, - bb: Rect2, - valign_offset: usize, - _spill: EnumMap, - clip: &Rect2, - ) { - let display = cell.display(); - let text = display.to_string(); - let horz_align = cell.horz_align(&display); - - use Axis2::*; - let breaks = new_line_breaks(&text, bb[X].len()); - for (text, y) in breaks.zip(bb[Y].start + valign_offset..bb[Y].end) { - let width = text.width(); - if !clip[Y].contains(&y) { - continue; - } - - let x = match horz_align { - HorzAlign::Right | HorzAlign::Decimal { .. } => bb[X].end - width, - HorzAlign::Left => bb[X].start, - HorzAlign::Center => (bb[X].start + bb[X].end - width).div_ceil(2), - }; - let Some((x, text)) = clip_text(text, &(x..x + width), &clip[X]) else { - continue; - }; - - let text = if self.emphasis { - Emphasis::from(&cell.style.font_style).apply(text) - } else { - Cow::from(text) - }; - self.get_line(y).put(x, &text); - } - } - - fn scale(&mut self, _factor: f64) { - unimplemented!() - } -} - -#[cfg(test)] -mod tests { - use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; - - use crate::output::text::new_line_breaks; - - #[test] - fn unicode_width() { - // `\n` is a control character, so [UnicodeWidthChar] considers it to - // have no width. - assert_eq!('\n'.width(), None); - - // But [UnicodeWidthStr] in unicode-width 0.1.14+ has a different idea. - assert_eq!("\n".width(), 1); - assert_eq!("\r\n".width(), 1); - } - - #[track_caller] - fn test_line_breaks(input: &str, width: usize, expected: Vec<&str>) { - let actual = new_line_breaks(input, width).collect::>(); - if expected != actual { - panic!( - "filling {input:?} to {width} columns:\nexpected: {expected:?}\nactual: {actual:?}" - ); - } - } - #[test] - fn line_breaks() { - test_line_breaks( - "One line of text\nOne line of text\n", - 16, - vec!["One line of text", "One line of text"], - ); - test_line_breaks("a b c\na b c\na b c\n", 5, vec!["a b c", "a b c", "a b c"]); - for width in 0..=6 { - test_line_breaks("abc def ghi", width, vec!["abc", "def", "ghi"]); - } - for width in 7..=10 { - test_line_breaks("abc def ghi", width, vec!["abc def", "ghi"]); - } - test_line_breaks("abc def ghi", 11, vec!["abc def ghi"]); - - for width in 0..=6 { - test_line_breaks("abc def ghi", width, vec!["abc", "def", "ghi"]); - } - test_line_breaks("abc def ghi", 7, vec!["abc", "def ghi"]); - for width in 8..=11 { - test_line_breaks("abc def ghi", width, vec!["abc def", "ghi"]); - } - test_line_breaks("abc def ghi", 12, vec!["abc def ghi"]); - - test_line_breaks("abc\ndef\nghi", 2, vec!["abc", "def", "ghi"]); - } -} diff --git a/rust/pspp/src/output/text_line.rs b/rust/pspp/src/output/text_line.rs deleted file mode 100644 index e4d7c5c370..0000000000 --- a/rust/pspp/src/output/text_line.rs +++ /dev/null @@ -1,610 +0,0 @@ -// PSPP - a program for statistical analysis. -// Copyright (C) 2025 Free Software Foundation, Inc. -// -// This program is free software: you can redistribute it and/or modify it under -// the terms of the GNU General Public License as published by the Free Software -// Foundation, either version 3 of the License, or (at your option) any later -// version. -// -// This program is distributed in the hope that it will be useful, but WITHOUT -// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -// details. -// -// You should have received a copy of the GNU General Public License along with -// this program. If not, see . - -use enum_iterator::Sequence; -use std::{ - borrow::Cow, - cmp::Ordering, - fmt::{Debug, Display}, - ops::Range, -}; - -use unicode_width::UnicodeWidthChar; - -use crate::output::pivot::FontStyle; - -/// A line of text, encoded in UTF-8, with support functions that properly -/// handle double-width characters and backspaces. -/// -/// Designed to make appending text fast, and access and modification of other -/// column positions possible. -#[derive(Clone, Default)] -pub struct TextLine { - /// Content. - string: String, - - /// Display width, in character positions. - width: usize, -} - -impl TextLine { - pub fn new() -> Self { - Self::default() - } - - pub fn clear(&mut self) { - self.string.clear(); - self.width = 0; - } - - /// Changes the width of this line to `x` columns. If `x` is longer than - /// the current width, extends the line with spaces. If `x` is shorter than - /// the current width, removes trailing characters. - pub fn resize(&mut self, x: usize) { - match x.cmp(&self.width) { - Ordering::Greater => self.string.extend((self.width..x).map(|_| ' ')), - Ordering::Less => { - let pos = self.find_pos(x); - self.string.truncate(pos.offsets.start); - if x > pos.offsets.start { - self.string.extend((pos.offsets.start..x).map(|_| '?')); - } - } - Ordering::Equal => return, - } - self.width = x; - } - - fn put_closure(&mut self, x0: usize, w: usize, push_str: F) - where - F: FnOnce(&mut String), - { - let x1 = x0 + w; - if w == 0 { - // Nothing to do. - } else if x0 >= self.width { - // The common case: adding new characters at the end of a line. - self.string.extend((self.width..x0).map(|_| ' ')); - push_str(&mut self.string); - self.width = x1; - } else if x1 >= self.width { - let p0 = self.find_pos(x0); - - // If a double-width character occupies both `x0 - 1` and `x0`, then - // replace its first character width by `?`. - self.string.truncate(p0.offsets.start); - self.string.extend((p0.columns.start..x0).map(|_| '?')); - push_str(&mut self.string); - self.width = x1; - } else { - let span = self.find_span(x0, x1); - let tail = self.string.split_off(span.offsets.end); - self.string.truncate(span.offsets.start); - self.string.extend((span.columns.start..x0).map(|_| '?')); - push_str(&mut self.string); - self.string.extend((x1..span.columns.end).map(|_| '?')); - self.string.push_str(&tail); - } - } - - pub fn put(&mut self, x0: usize, s: &str) { - self.string.reserve(s.len()); - self.put_closure(x0, Widths::new(s).sum(), |dst| dst.push_str(s)); - } - - pub fn put_multiple(&mut self, x0: usize, c: char, n: usize) { - self.string.reserve(c.len_utf8() * n); - self.put_closure(x0, c.width().unwrap() * n, |dst| { - (0..n).for_each(|_| dst.push(c)) - }); - } - - fn find_span(&self, x0: usize, x1: usize) -> Position { - debug_assert!(x1 > x0); - let p0 = self.find_pos(x0); - let p1 = self.find_pos(x1 - 1); - Position { - columns: p0.columns.start..p1.columns.end, - offsets: p0.offsets.start..p1.offsets.end, - } - } - - // Returns the [Position] that contains column `target_x`. - fn find_pos(&self, target_x: usize) -> Position { - let mut x = 0; - let mut ofs = 0; - let mut widths = Widths::new(&self.string); - while let Some(w) = widths.next() { - if x + w > target_x { - return Position { - columns: x..x + w, - offsets: ofs..widths.offset(), - }; - } - ofs = widths.offset(); - x += w; - } - - // This can happen if there are non-printable characters in a line. - Position { - columns: x..x, - offsets: ofs..ofs, - } - } - - pub fn str(&self) -> &str { - &self.string - } -} - -impl Display for TextLine { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.string) - } -} - -/// Position of one or more characters within a [TextLine]. -#[derive(Debug)] -struct Position { - /// 0-based display columns. - columns: Range, - - /// Byte offests. - offsets: Range, -} - -/// Iterates through the column widths in a string. -struct Widths<'a> { - s: &'a str, - base: &'a str, -} - -impl<'a> Widths<'a> { - fn new(s: &'a str) -> Self { - Self { s, base: s } - } - - /// Returns the amount of the string remaining to be visited. - fn as_str(&self) -> &str { - self.s - } - - // Returns the offset into the original string of the characters remaining - // to be visited. - fn offset(&self) -> usize { - self.base.len() - self.s.len() - } -} - -impl Iterator for Widths<'_> { - type Item = usize; - - fn next(&mut self) -> Option { - let mut iter = self.s.char_indices(); - let (_, mut c) = iter.next()?; - while iter.as_str().starts_with('\x08') { - iter.next(); - c = match iter.next() { - Some((_, c)) => c, - _ => { - self.s = iter.as_str(); - return Some(0); - } - }; - } - - let w = c.width().unwrap_or_default(); - if w == 0 { - self.s = iter.as_str(); - return Some(0); - } - - for (index, c) in iter { - if c.width().is_some_and(|width| width > 0) { - self.s = &self.s[index..]; - return Some(w); - } - } - self.s = ""; - Some(w) - } -} - -#[derive(Copy, Clone, PartialEq, Eq, Sequence)] -pub struct Emphasis { - pub bold: bool, - pub underline: bool, -} - -impl From<&FontStyle> for Emphasis { - fn from(style: &FontStyle) -> Self { - Self { - bold: style.bold, - underline: style.underline, - } - } -} - -impl Emphasis { - const fn plain() -> Self { - Self { - bold: false, - underline: false, - } - } - pub fn is_plain(&self) -> bool { - *self == Self::plain() - } - pub fn apply<'a>(&self, s: &'a str) -> Cow<'a, str> { - if self.is_plain() { - Cow::from(s) - } else { - let mut output = String::with_capacity( - s.len() * (1 + self.bold as usize * 2 + self.underline as usize * 2), - ); - for c in s.chars() { - if self.bold { - output.push(c); - output.push('\x08'); - } - if self.underline { - output.push('_'); - output.push('\x08'); - } - output.push(c); - } - Cow::from(output) - } - } -} - -impl Debug for Emphasis { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Self { - bold: false, - underline: false, - } => "plain", - Self { - bold: true, - underline: false, - } => "bold", - Self { - bold: false, - underline: true, - } => "underline", - Self { - bold: true, - underline: true, - } => "bold+underline", - } - ) - } -} - -pub fn clip_text<'a>( - text: &'a str, - bb: &Range, - clip: &Range, -) -> Option<(usize, &'a str)> { - let mut x = bb.start; - let mut width = bb.len(); - - let mut iter = text.chars(); - while x < clip.start { - let c = iter.next()?; - if let Some(w) = c.width() { - x += w; - width = width.checked_sub(w)?; - } - } - if x + width > clip.end { - if x >= clip.end { - return None; - } - - while x + width > clip.end { - let c = iter.next_back()?; - if let Some(w) = c.width() { - width = width.checked_sub(w)?; - } - } - } - Some((x, iter.as_str())) -} - -#[cfg(test)] -mod tests { - use super::{Emphasis, TextLine}; - use enum_iterator::all; - - #[test] - fn overwrite_rest_of_line() { - for lowercase in all::() { - for uppercase in all::() { - let mut line = TextLine::new(); - line.put(0, &lowercase.apply("abc")); - line.put(1, &uppercase.apply("BCD")); - assert_eq!( - line.str(), - &format!("{}{}", lowercase.apply("a"), uppercase.apply("BCD")), - "uppercase={uppercase:?} lowercase={lowercase:?}" - ); - } - } - } - - #[test] - fn overwrite_partial_line() { - for lowercase in all::() { - for uppercase in all::() { - let mut line = TextLine::new(); - // Produces `AbCDEf`. - line.put(0, &lowercase.apply("abcdef")); - line.put(0, &uppercase.apply("A")); - line.put(2, &uppercase.apply("CDE")); - assert_eq!( - line.str().replace('\x08', "#"), - format!( - "{}{}{}{}", - uppercase.apply("A"), - lowercase.apply("b"), - uppercase.apply("CDE"), - lowercase.apply("f") - ) - .replace('\x08', "#"), - "uppercase={uppercase:?} lowercase={lowercase:?}" - ); - } - } - } - - #[test] - fn overwrite_rest_with_double_width() { - for lowercase in all::() { - for hiragana in all::() { - let mut line = TextLine::new(); - // Produces `kaきくけ"`. - line.put(0, &lowercase.apply("kakiku")); - line.put(2, &hiragana.apply("きくけ")); - assert_eq!( - line.str(), - &format!("{}{}", lowercase.apply("ka"), hiragana.apply("きくけ")), - "lowercase={lowercase:?} hiragana={hiragana:?}" - ); - } - } - } - - #[test] - fn overwrite_partial_with_double_width() { - for lowercase in all::() { - for hiragana in all::() { - let mut line = TextLine::new(); - // Produces `かkiくけko". - line.put(0, &lowercase.apply("kakikukeko")); - line.put(0, &hiragana.apply("か")); - line.put(4, &hiragana.apply("くけ")); - assert_eq!( - line.str(), - &format!( - "{}{}{}{}", - hiragana.apply("か"), - lowercase.apply("ki"), - hiragana.apply("くけ"), - lowercase.apply("ko") - ), - "lowercase={lowercase:?} hiragana={hiragana:?}" - ); - } - } - } - - /// Overwrite rest of line, aligned double-width over double-width - #[test] - fn aligned_double_width_rest_of_line() { - for bottom in all::() { - for top in all::() { - let mut line = TextLine::new(); - // Produces `あきくけ`. - line.put(0, &bottom.apply("あいう")); - line.put(2, &top.apply("きくけ")); - assert_eq!( - line.str(), - &format!("{}{}", bottom.apply("あ"), top.apply("きくけ")), - "bottom={bottom:?} top={top:?}" - ); - } - } - } - - /// Overwrite rest of line, misaligned double-width over double-width - #[test] - fn misaligned_double_width_rest_of_line() { - for bottom in all::() { - for top in all::() { - let mut line = TextLine::new(); - // Produces `あきくけ`. - line.put(0, &bottom.apply("あいう")); - line.put(3, &top.apply("きくけ")); - assert_eq!( - line.str(), - &format!("{}?{}", bottom.apply("あ"), top.apply("きくけ")), - "bottom={bottom:?} top={top:?}" - ); - } - } - } - - /// Overwrite partial line, aligned double-width over double-width - #[test] - fn aligned_double_width_partial() { - for bottom in all::() { - for top in all::() { - let mut line = TextLine::new(); - // Produces `かいくけお`. - line.put(0, &bottom.apply("あいうえお")); - line.put(0, &top.apply("か")); - line.put(4, &top.apply("くけ")); - assert_eq!( - line.str(), - &format!( - "{}{}{}{}", - top.apply("か"), - bottom.apply("い"), - top.apply("くけ"), - bottom.apply("お") - ), - "bottom={bottom:?} top={top:?}" - ); - } - } - } - - /// Overwrite partial line, misaligned double-width over double-width - #[test] - fn misaligned_double_width_partial() { - for bottom in all::() { - for top in all::() { - let mut line = TextLine::new(); - // Produces `?か?うえおさ`. - line.put(0, &bottom.apply("あいうえおさ")); - line.put(1, &top.apply("か")); - assert_eq!( - line.str(), - &format!("?{}?{}", top.apply("か"), bottom.apply("うえおさ"),), - "bottom={bottom:?} top={top:?}" - ); - - // Produces `?か??くけ?さ`. - line.put(5, &top.apply("くけ")); - assert_eq!( - line.str(), - &format!( - "?{}??{}?{}", - top.apply("か"), - top.apply("くけ"), - bottom.apply("さ") - ), - "bottom={bottom:?} top={top:?}" - ); - } - } - } - - /// Overwrite rest of line, aligned single-width over double-width. - #[test] - fn aligned_rest_single_over_double() { - for bottom in all::() { - for top in all::() { - let mut line = TextLine::new(); - // Produces `あkikuko`. - line.put(0, &bottom.apply("あいう")); - line.put(2, &top.apply("kikuko")); - assert_eq!( - line.str(), - &format!("{}{}", bottom.apply("あ"), top.apply("kikuko"),), - "bottom={bottom:?} top={top:?}" - ); - } - } - } - - /// Overwrite rest of line, misaligned single-width over double-width. - #[test] - fn misaligned_rest_single_over_double() { - for bottom in all::() { - for top in all::() { - let mut line = TextLine::new(); - // Produces `あ?kikuko`. - line.put(0, &bottom.apply("あいう")); - line.put(3, &top.apply("kikuko")); - assert_eq!( - line.str(), - &format!("{}?{}", bottom.apply("あ"), top.apply("kikuko"),), - "bottom={bottom:?} top={top:?}" - ); - } - } - } - - /// Overwrite partial line, aligned single-width over double-width. - #[test] - fn aligned_partial_single_over_double() { - for bottom in all::() { - for top in all::() { - let mut line = TextLine::new(); - // Produces `kaいうえお`. - line.put(0, &bottom.apply("あいうえお")); - line.put(0, &top.apply("ka")); - assert_eq!( - line.str(), - &format!("{}{}", top.apply("ka"), bottom.apply("いうえお"),), - "bottom={bottom:?} top={top:?}" - ); - - // Produces `kaいkukeお`. - line.put(4, &top.apply("kuke")); - assert_eq!( - line.str(), - &format!( - "{}{}{}{}", - top.apply("ka"), - bottom.apply("い"), - top.apply("kuke"), - bottom.apply("お") - ), - "bottom={bottom:?} top={top:?}" - ); - } - } - } - - /// Overwrite partial line, misaligned single-width over double-width. - #[test] - fn misaligned_partial_single_over_double() { - for bottom in all::() { - for top in all::() { - let mut line = TextLine::new(); - // Produces `?aいうえおさ`. - line.put(0, &bottom.apply("あいうえおさ")); - line.put(1, &top.apply("a")); - assert_eq!( - line.str(), - &format!("?{}{}", top.apply("a"), bottom.apply("いうえおさ"),), - "bottom={bottom:?} top={top:?}" - ); - - // Produces `?aい?kuke?さ`. - line.put(5, &top.apply("kuke")); - assert_eq!( - line.str(), - &format!( - "?{}{}?{}?{}", - top.apply("a"), - bottom.apply("い"), - top.apply("kuke"), - bottom.apply("さ") - ), - "bottom={bottom:?} top={top:?}" - ); - } - } - } -} diff --git a/rust/pspp/src/show.rs b/rust/pspp/src/show.rs index 2a866c5d47..494d322e27 100644 --- a/rust/pspp/src/show.rs +++ b/rust/pspp/src/show.rs @@ -22,7 +22,7 @@ use pspp::{ data::cases_to_output, output::{ Details, Item, Text, - driver::{Config, Driver}, + drivers::{Config, Driver}, pivot::PivotTable, }, sys::{ diff --git a/rust/pspp/src/show_pc.rs b/rust/pspp/src/show_pc.rs index 385f8770be..a6dea1286a 100644 --- a/rust/pspp/src/show_pc.rs +++ b/rust/pspp/src/show_pc.rs @@ -20,7 +20,7 @@ use pspp::{ data::cases_to_output, output::{ Details, Item, Text, - driver::{Config, Driver}, + drivers::{Config, Driver}, pivot::PivotTable, }, pc::PcFile, diff --git a/rust/pspp/src/show_por.rs b/rust/pspp/src/show_por.rs index d0ba365eca..f3e6ca6369 100644 --- a/rust/pspp/src/show_por.rs +++ b/rust/pspp/src/show_por.rs @@ -20,7 +20,7 @@ use pspp::{ data::cases_to_output, output::{ Details, Item, Text, - driver::{Config, Driver}, + drivers::{Config, Driver}, pivot::PivotTable, }, por::PortableFile, diff --git a/rust/pspp/src/sys/write.rs b/rust/pspp/src/sys/write.rs index 741a9dd423..350f7d0720 100644 --- a/rust/pspp/src/sys/write.rs +++ b/rust/pspp/src/sys/write.rs @@ -37,7 +37,7 @@ use crate::{ dictionary::{CategoryLabels, Dictionary, MultipleResponseType}, format::{DisplayPlain, Format}, identifier::Identifier, - output::spv::Zeros, + output::drivers::spv::Zeros, sys::{ ProductVersion, encoding::codepage_from_encoding,