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`
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
/// 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,
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)]
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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();
- }
-}
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<PageSetup>,
-}
-
-impl CairoConfig {
- pub fn new(path: impl AsRef<Path>) -> Self {
- Self {
- file: path.as_ref().to_path_buf(),
- page_setup: None,
- }
- }
-}
-
-pub struct CairoDriver {
- fsm_style: Arc<CairoFsmStyle>,
- page_style: Arc<CairoPageStyle>,
- pager: Option<CairoPager>,
- surface: PdfSurface,
-}
-
-impl CairoDriver {
- pub fn new(config: &CairoConfig) -> cairo::Result<Self> {
- 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<Item>) {
- 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();
- }
- }
-}
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<Axis2, usize>,
-
- /// 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<CairoFsmStyle>,
- params: Params,
- item: Arc<Item>,
- layer_iterator: Option<Box<dyn Iterator<Item = SmallVec<[usize; 4]>>>>,
- pager: Option<Pager>,
-}
-
-impl CairoFsm {
- pub fn new(
- style: Arc<CairoFsmStyle>,
- printing: bool,
- context: &Context,
- item: Arc<Item>,
- ) -> 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::<i32>()
- .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<T: DerefMut<Target = Attribute>>(index: usize, mut attr: T) -> T {
- attr.deref_mut().set_start_index(index.try_into().unwrap());
- attr
- }
- fn with_end<T: DerefMut<Target = Attribute>>(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<Extreme, usize> {
- 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<Axis2, [BorderStyle; 2]>) {
- 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<Axis2, [usize; 2]>,
- 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);
- }
-}
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<Axis2, [usize; 2]>,
- pub headings: [Heading; 2],
- pub initial_page_number: i32,
-}
-
-pub struct CairoPager {
- page_style: Arc<CairoPageStyle>,
- fsm_style: Arc<CairoFsmStyle>,
- page_index: i32,
- heading_heights: [usize; 2],
- iter: Option<ItemCursor>,
- context: Option<Context>,
- fsm: Option<CairoFsm>,
- y: usize,
-}
-
-impl CairoPager {
- pub fn new(mut page_style: Arc<CairoPageStyle>, mut fsm_style: Arc<CairoFsmStyle>) -> Self {
- let heading_heights = measure_headings(&page_style, &fsm_style);
- let total = heading_heights.iter().sum::<usize>();
- 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<Item>) {
- 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
-}
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<File>,
- 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<u8, D::Error>
-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<E>(self, s: &str) -> Result<u8, E>
- 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<Self> {
- 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<Item>) {
- // 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();
- }
-}
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<Item>);
-
- /// 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<dyn Driver> {
- fn name(&self) -> Cow<'static, str> {
- (**self).name()
- }
-
- fn write(&mut self, item: &Arc<Item>) {
- (**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<Box<Self>> {
- 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<Path>) -> 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()
- );
- }
-}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<Item>);
+
+ /// 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<dyn Driver> {
+ fn name(&self) -> Cow<'static, str> {
+ (**self).name()
+ }
+
+ fn write(&mut self, item: &Arc<Item>) {
+ (**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<Box<Self>> {
+ 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<Path>) -> 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()
+ );
+ }
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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();
+ }
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<PageSetup>,
+}
+
+impl CairoConfig {
+ pub fn new(path: impl AsRef<Path>) -> Self {
+ Self {
+ file: path.as_ref().to_path_buf(),
+ page_setup: None,
+ }
+ }
+}
+
+pub struct CairoDriver {
+ fsm_style: Arc<CairoFsmStyle>,
+ page_style: Arc<CairoPageStyle>,
+ pager: Option<CairoPager>,
+ surface: PdfSurface,
+}
+
+impl CairoDriver {
+ pub fn new(config: &CairoConfig) -> cairo::Result<Self> {
+ 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<Item>) {
+ 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();
+ }
+ }
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<Axis2, usize>,
+
+ /// 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<CairoFsmStyle>,
+ params: Params,
+ item: Arc<Item>,
+ layer_iterator: Option<Box<dyn Iterator<Item = SmallVec<[usize; 4]>>>>,
+ pager: Option<Pager>,
+}
+
+impl CairoFsm {
+ pub fn new(
+ style: Arc<CairoFsmStyle>,
+ printing: bool,
+ context: &Context,
+ item: Arc<Item>,
+ ) -> 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::<i32>()
+ .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<T: DerefMut<Target = Attribute>>(index: usize, mut attr: T) -> T {
+ attr.deref_mut().set_start_index(index.try_into().unwrap());
+ attr
+ }
+ fn with_end<T: DerefMut<Target = Attribute>>(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<Extreme, usize> {
+ 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<Axis2, [BorderStyle; 2]>) {
+ 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<Axis2, [usize; 2]>,
+ 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);
+ }
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<Axis2, [usize; 2]>,
+ pub headings: [Heading; 2],
+ pub initial_page_number: i32,
+}
+
+pub struct CairoPager {
+ page_style: Arc<CairoPageStyle>,
+ fsm_style: Arc<CairoFsmStyle>,
+ page_index: i32,
+ heading_heights: [usize; 2],
+ iter: Option<ItemCursor>,
+ context: Option<Context>,
+ fsm: Option<CairoFsm>,
+ y: usize,
+}
+
+impl CairoPager {
+ pub fn new(mut page_style: Arc<CairoPageStyle>, mut fsm_style: Arc<CairoFsmStyle>) -> Self {
+ let heading_heights = measure_headings(&page_style, &fsm_style);
+ let total = heading_heights.iter().sum::<usize>();
+ 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<Item>) {
+ 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
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<File>,
+ 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<u8, D::Error>
+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<E>(self, s: &str) -> Result<u8, E>
+ 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<Self> {
+ 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<Item>) {
+ // 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();
+ }
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<W> {
+ 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<File> {
+ pub fn new(config: &HtmlConfig) -> std::io::Result<Self> {
+ Ok(Self::for_writer(File::create(&config.file)?))
+ }
+}
+
+impl<W> HtmlDriver<W>
+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, "<table")?;
+ if let Some(notes) = &pivot_table.notes {
+ write!(&mut self.writer, r#" title="{}""#, Escape::new(notes))?;
+ }
+ writeln!(&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, "<thead>")?;
+ for cell in layers.cells() {
+ writeln!(&mut self.writer, "<tr>")?;
+ 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, "</tr>")?;
+ }
+ writeln!(&mut self.writer, "</thead>")?;
+ }
+
+ writeln!(&mut self.writer, "<tbody>")?;
+ for y in 0..output.body.n.y() {
+ writeln!(&mut self.writer, "<tr>")?;
+ 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, "</tr>")?;
+ }
+ writeln!(&mut self.writer, "</tbody>")?;
+
+ if output.caption.is_some() || output.footnotes.is_some() {
+ writeln!(&mut self.writer, "<tfoot>")?;
+ writeln!(&mut self.writer, "<tr>")?;
+ 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, "</tr>")?;
+
+ if let Some(footnotes) = output.footnotes {
+ for cell in footnotes.cells() {
+ writeln!(&mut self.writer, "<tr>")?;
+ 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, "</tr>")?;
+ }
+ }
+ writeln!(&mut self.writer, "</tfoot>")?;
+ }
+ }
+ 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("<br>")
+ )?;
+
+ if suffixes.has_subscripts() {
+ write!(&mut self.writer, "<sub>")?;
+ 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("<br>")
+ )?;
+ }
+ write!(&mut self.writer, "</sub>")?;
+ }
+
+ if suffixes.has_footnotes() {
+ write!(&mut self.writer, "<sup>")?;
+ 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("<br>")
+ )?;
+ }
+ write!(&mut self.writer, "</sup>")?;
+ }
+
+ writeln!(&mut self.writer, "</{tag}>")
+ }
+
+ 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<W>(mut writer: W) -> std::io::Result<()>
+where
+ W: Write,
+{
+ write!(
+ &mut writer,
+ r#"<!doctype html>
+<html>
+<head>
+<title>PSPP Output</title>
+<meta name="generator" content="PSPP {}"/>
+<meta http-equiv="content-type" content="text/html; charset=utf8"/>
+{}
+"#,
+ Escape::new(env!("CARGO_PKG_VERSION")),
+ HEADER_CSS,
+ )?;
+ Ok(())
+}
+
+const HEADER_CSS: &str = r#"<style>
+body {
+ background: white;
+ color: black;
+ padding: 0em 12em 0em 3em;
+ margin: 0
+}
+body>p {
+ margin: 0pt 0pt 0pt 0em
+}
+body>p + p {
+ text-indent: 1.5em;
+}
+h1 {
+ font-size: 150%;
+ margin-left: -1.33em
+}
+h2 {
+ font-size: 125%;
+ font-weight: bold;
+ margin-left: -.8em
+}
+h3 {
+ font-size: 100%;
+ font-weight: bold;
+ margin-left: -.5em }
+h4 {
+ font-size: 100%;
+ margin-left: 0em
+}
+h1, h2, h3, h4, h5, h6 {
+ font-family: sans-serif;
+ color: blue
+}
+html {
+ margin: 0
+}
+code {
+ font-family: sans-serif
+}
+table {
+ border-collapse: collapse;
+ margin-bottom: 1em
+}
+caption {
+ text-align: left;
+ width: 100%
+}
+th { font-weight: normal }
+a:link {
+ color: #1f00ff;
+}
+a:visited {
+ color: #9900dd;
+}
+a:active {
+ color: red;
+}
+</style>
+</head>
+<body>
+"#;
+
+impl<W> Driver for HtmlDriver<W>
+where
+ W: Write,
+{
+ fn name(&self) -> Cow<'static, str> {
+ Cow::from("html")
+ }
+
+ fn write(&mut self, item: &Arc<Item>) {
+ 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(())
+ }
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<File>,
+}
+
+impl JsonDriver {
+ pub fn new(config: &JsonConfig) -> std::io::Result<Self> {
+ 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<Item>) {
+ serde_json::to_writer_pretty(&mut self.file, item).unwrap(); // XXX handle errors
+ }
+
+ fn flush(&mut self) {
+ let _ = self.file.flush();
+ }
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<PageSetup>,
+}
+
+pub struct SpvDriver<W>
+where
+ W: Write + Seek,
+{
+ writer: ZipWriter<W>,
+ needs_page_break: bool,
+ next_table_id: u64,
+ next_heading_id: u64,
+ page_setup: Option<PageSetup>,
+}
+
+impl SpvDriver<File> {
+ pub fn new(config: &SpvConfig) -> std::io::Result<Self> {
+ 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<W> SpvDriver<W>
+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<W> {
+ 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<X>(
+ &mut self,
+ item: &Item,
+ pivot_table: &PivotTable,
+ structure: &mut XmlWriter<X>,
+ ) 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<X>(&mut self, item: &Item, text: &Text, structure: &mut XmlWriter<X>)
+ 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<X>(&mut self, item: &Item, structure: &mut XmlWriter<X>)
+ where
+ X: Write,
+ {
+ match &item.details {
+ Details::Chart => todo!(),
+ Details::Image => todo!(),
+ Details::Group(children) => {
+ let mut attributes = Vec::<Attribute>::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<X, F>(
+ &mut self,
+ writer: &mut XmlWriter<X>,
+ item: &Item,
+ inner_elem: &str,
+ closure: F,
+ ) where
+ X: Write,
+ F: FnOnce(ElementWriter<X>),
+ {
+ 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<W: Write + Seek>(
+ &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<Args<'a> = ()> {
+ (
+ pivot_table.settings.epoch.0 as u32,
+ u8::from(pivot_table.settings.decimal),
+ b',',
+ )
+ }
+
+ fn custom_currency(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> {
+ (
+ 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<Args<'a> = ()> {
+ (
+ 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<Args<'a> = ()> {
+ Counted::new((
+ 0u32, // n-row-heights
+ 0u32, // n-style-maps
+ 0u32, // n-styles,
+ 0u32,
+ ))
+ }
+
+ fn y1(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> + 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<Args<'a> = ()> {
+ (custom_currency(pivot_table), b'.', SpvBool(false))
+ }
+
+ fn x3(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> + 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<W> Driver for SpvDriver<W>
+where
+ W: Write + Seek,
+{
+ fn name(&self) -> Cow<'static, str> {
+ Cow::from("spv")
+ }
+
+ fn write(&mut self, item: &Arc<Item>) {
+ 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<X>(page_setup: &PageSetup, writer: &mut XmlWriter<X>) -> 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<X>(
+ heading: &Heading,
+ name: &str,
+ writer: &mut XmlWriter<X>,
+) -> 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<I>,
+) -> ElementWriter<'a, W>
+where
+ I: Into<Attribute<'b>>,
+{
+ if let Some(attr) = attr {
+ element.with_attribute(attr)
+ } else {
+ element
+ }
+}
+
+impl BinWrite for Dimension {
+ type Args<'a> = (usize, u8);
+
+ fn write_options<W: Write + Seek>(
+ &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<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
+ where
+ W: Write + Seek,
+ D: Iterator<Item = usize>,
+ {
+ 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<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
+ where
+ W: Write + Seek,
+ D: Iterator<Item = usize>,
+ {
+ (
+ self.name(),
+ 0u8,
+ 0u8,
+ 0u8,
+ 2u32,
+ data_indexes.next().unwrap() as u32,
+ 0u32,
+ )
+ .write_le(writer)
+ }
+}
+
+impl Group {
+ fn write_le<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
+ where
+ W: Write + Seek,
+ D: Iterator<Item = usize>,
+ {
+ (
+ 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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &self,
+ writer: &mut W,
+ endian: binrw::Endian,
+ args: Self::Args<'_>,
+ ) -> binrw::BinResult<()> {
+ (self.0 as u8).write_options(writer, endian, args)
+ }
+}
+
+struct SpvString<T>(T);
+impl<'a> SpvString<&'a str> {
+ fn optional(s: &'a Option<String>) -> Self {
+ Self(s.as_ref().map_or("", |s| s.as_str()))
+ }
+}
+impl<T> BinWrite for SpvString<T>
+where
+ T: AsRef<str>,
+{
+ type Args<'a> = ();
+
+ fn write_options<W: Write + Seek>(
+ &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<Show>) -> u8 {
+ match this {
+ None => 0,
+ Some(Show::Value) => 1,
+ Some(Show::Label) => 2,
+ Some(Show::Both) => 3,
+ }
+ }
+}
+
+struct Count(u64);
+
+impl Count {
+ fn new<W>(writer: &mut W) -> binrw::BinResult<Self>
+ where
+ W: Write + Seek,
+ {
+ 0u32.write_le(writer)?;
+ Ok(Self(writer.stream_position()?))
+ }
+
+ fn finish<W>(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<W>(self, writer: &mut W) -> binrw::BinResult<()>
+ where
+ W: Write + Seek,
+ {
+ self.finish(writer, Endian::Little)
+ }
+
+ fn finish_be32<W>(self, writer: &mut W) -> binrw::BinResult<()>
+ where
+ W: Write + Seek,
+ {
+ self.finish(writer, Endian::Big)
+ }
+}
+
+struct Counted<T> {
+ inner: T,
+ endian: Option<Endian>,
+}
+
+impl<T> Counted<T> {
+ fn new(inner: T) -> Self {
+ Self {
+ inner,
+ endian: None,
+ }
+ }
+ fn with_endian(self, endian: Endian) -> Self {
+ Self {
+ inner: self.inner,
+ endian: Some(endian),
+ }
+ }
+}
+
+impl<T> BinWrite for Counted<T>
+where
+ T: BinWrite,
+ for<'a> T: BinWrite<Args<'a> = ()>,
+{
+ type Args<'a> = T::Args<'a>;
+
+ fn write_options<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<f64> {
+ 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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<T>(Option<T>);
+
+impl<T> BinWrite for Optional<T>
+where
+ T: BinWrite,
+{
+ type Args<'a> = T::Args<'a>;
+
+ fn write_options<W: Write + Seek>(
+ &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<Box<ValueStyle>>,
+ 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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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<W: Write + Seek>(
+ &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(())
+ }
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<PathBuf>,
+
+ /// 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<usize>,
+
+ /// 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<TextLine>,
+}
+
+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<Stroke> 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<Lines, char>);
+
+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<Lines> for BoxChars {
+ type Output = char;
+
+ fn index(&self, lines: Lines) -> &Self::Output {
+ &self.0[lines]
+ }
+}
+
+static ASCII_BOX: LazyLock<BoxChars> = 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<BoxChars> = 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<File>,
+ renderer: TextRenderer,
+}
+
+impl TextDriver {
+ pub fn new(config: &TextConfig) -> std::io::Result<TextDriver> {
+ 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<W>(&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<W>(&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<Item = (usize, BreakOpportunity)> + Clone + 'a,
+{
+ text: &'a str,
+ max_width: usize,
+ indexes: Range<usize>,
+ width: usize,
+ saved: Option<(usize, BreakOpportunity)>,
+ breaks: B,
+ trailing_newlines: usize,
+}
+
+impl<'a, B> Iterator for LineBreaks<'a, B>
+where
+ B: Iterator<Item = (usize, BreakOpportunity)> + Clone + 'a,
+{
+ type Item = &'a str;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ 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<Item = (usize, BreakOpportunity)> + 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<Item>) {
+ let _ = self.renderer.render(item, &mut FmtAdapter(&mut self.file));
+ }
+}
+
+struct FmtAdapter<W>(W);
+
+impl<W> FmtWrite for FmtAdapter<W>
+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<Extreme, usize> {
+ 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<Axis2, [BorderStyle; 2]>) {
+ 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<Axis2, [usize; 2]>,
+ 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::<Vec<_>>();
+ 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"]);
+ }
+}
--- /dev/null
+// 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 <http://www.gnu.org/licenses/>.
+
+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<F>(&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<usize>,
+
+ /// Byte offests.
+ offsets: Range<usize>,
+}
+
+/// 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<Self::Item> {
+ 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<usize>,
+ clip: &Range<usize>,
+) -> 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::<Emphasis>() {
+ for uppercase in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for uppercase in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for hiragana in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for hiragana in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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::<Emphasis>() {
+ for top in all::<Emphasis>() {
+ 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:?}"
+ );
+ }
+ }
+ }
+}
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<W> {
- 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<File> {
- pub fn new(config: &HtmlConfig) -> std::io::Result<Self> {
- Ok(Self::for_writer(File::create(&config.file)?))
- }
-}
-
-impl<W> HtmlDriver<W>
-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, "<table")?;
- if let Some(notes) = &pivot_table.notes {
- write!(&mut self.writer, r#" title="{}""#, Escape::new(notes))?;
- }
- writeln!(&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, "<thead>")?;
- for cell in layers.cells() {
- writeln!(&mut self.writer, "<tr>")?;
- 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, "</tr>")?;
- }
- writeln!(&mut self.writer, "</thead>")?;
- }
-
- writeln!(&mut self.writer, "<tbody>")?;
- for y in 0..output.body.n.y() {
- writeln!(&mut self.writer, "<tr>")?;
- 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, "</tr>")?;
- }
- writeln!(&mut self.writer, "</tbody>")?;
-
- if output.caption.is_some() || output.footnotes.is_some() {
- writeln!(&mut self.writer, "<tfoot>")?;
- writeln!(&mut self.writer, "<tr>")?;
- 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, "</tr>")?;
-
- if let Some(footnotes) = output.footnotes {
- for cell in footnotes.cells() {
- writeln!(&mut self.writer, "<tr>")?;
- 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, "</tr>")?;
- }
- }
- writeln!(&mut self.writer, "</tfoot>")?;
- }
- }
- 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("<br>")
- )?;
-
- if suffixes.has_subscripts() {
- write!(&mut self.writer, "<sub>")?;
- 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("<br>")
- )?;
- }
- write!(&mut self.writer, "</sub>")?;
- }
-
- if suffixes.has_footnotes() {
- write!(&mut self.writer, "<sup>")?;
- 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("<br>")
- )?;
- }
- write!(&mut self.writer, "</sup>")?;
- }
-
- writeln!(&mut self.writer, "</{tag}>")
- }
-
- 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<W>(mut writer: W) -> std::io::Result<()>
-where
- W: Write,
-{
- write!(
- &mut writer,
- r#"<!doctype html>
-<html>
-<head>
-<title>PSPP Output</title>
-<meta name="generator" content="PSPP {}"/>
-<meta http-equiv="content-type" content="text/html; charset=utf8"/>
-{}
-"#,
- Escape::new(env!("CARGO_PKG_VERSION")),
- HEADER_CSS,
- )?;
- Ok(())
-}
-
-const HEADER_CSS: &str = r#"<style>
-body {
- background: white;
- color: black;
- padding: 0em 12em 0em 3em;
- margin: 0
-}
-body>p {
- margin: 0pt 0pt 0pt 0em
-}
-body>p + p {
- text-indent: 1.5em;
-}
-h1 {
- font-size: 150%;
- margin-left: -1.33em
-}
-h2 {
- font-size: 125%;
- font-weight: bold;
- margin-left: -.8em
-}
-h3 {
- font-size: 100%;
- font-weight: bold;
- margin-left: -.5em }
-h4 {
- font-size: 100%;
- margin-left: 0em
-}
-h1, h2, h3, h4, h5, h6 {
- font-family: sans-serif;
- color: blue
-}
-html {
- margin: 0
-}
-code {
- font-family: sans-serif
-}
-table {
- border-collapse: collapse;
- margin-bottom: 1em
-}
-caption {
- text-align: left;
- width: 100%
-}
-th { font-weight: normal }
-a:link {
- color: #1f00ff;
-}
-a:visited {
- color: #9900dd;
-}
-a:active {
- color: red;
-}
-</style>
-</head>
-<body>
-"#;
-
-impl<W> Driver for HtmlDriver<W>
-where
- W: Write,
-{
- fn name(&self) -> Cow<'static, str> {
- Cow::from("html")
- }
-
- fn write(&mut self, item: &Arc<Item>) {
- 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(())
- }
-}
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<File>,
-}
-
-impl JsonDriver {
- pub fn new(config: &JsonConfig) -> std::io::Result<Self> {
- 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<Item>) {
- serde_json::to_writer_pretty(&mut self.file, item).unwrap(); // XXX handle errors
- }
-
- fn flush(&mut self) {
- let _ = self.file.flush();
- }
-}
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};
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<PageSetup>,
-}
-
-pub struct SpvDriver<W>
-where
- W: Write + Seek,
-{
- writer: ZipWriter<W>,
- needs_page_break: bool,
- next_table_id: u64,
- next_heading_id: u64,
- page_setup: Option<PageSetup>,
-}
-
-impl SpvDriver<File> {
- pub fn new(config: &SpvConfig) -> std::io::Result<Self> {
- 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<W> SpvDriver<W>
-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<W> {
- 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<X>(
- &mut self,
- item: &Item,
- pivot_table: &PivotTable,
- structure: &mut XmlWriter<X>,
- ) 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<X>(&mut self, item: &Item, text: &Text, structure: &mut XmlWriter<X>)
- 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<X>(&mut self, item: &Item, structure: &mut XmlWriter<X>)
- where
- X: Write,
- {
- match &item.details {
- super::Details::Chart => todo!(),
- super::Details::Image => todo!(),
- super::Details::Group(children) => {
- let mut attributes = Vec::<Attribute>::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<X, F>(
- &mut self,
- writer: &mut XmlWriter<X>,
- item: &Item,
- inner_elem: &str,
- closure: F,
- ) where
- X: Write,
- F: FnOnce(ElementWriter<X>),
- {
- 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<W: Write + Seek>(
- &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<Args<'a> = ()> {
- (
- pivot_table.settings.epoch.0 as u32,
- u8::from(pivot_table.settings.decimal),
- b',',
- )
- }
-
- fn custom_currency(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> {
- (
- 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<Args<'a> = ()> {
- (
- 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<Args<'a> = ()> {
- Counted::new((
- 0u32, // n-row-heights
- 0u32, // n-style-maps
- 0u32, // n-styles,
- 0u32,
- ))
- }
-
- fn y1(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> + 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<Args<'a> = ()> {
- (custom_currency(pivot_table), b'.', SpvBool(false))
- }
-
- fn x3(pivot_table: &PivotTable) -> impl for<'a> BinWrite<Args<'a> = ()> + 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<W> Driver for SpvDriver<W>
-where
- W: Write + Seek,
-{
- fn name(&self) -> Cow<'static, str> {
- Cow::from("spv")
- }
-
- fn write(&mut self, item: &Arc<Item>) {
- 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<X>(page_setup: &PageSetup, writer: &mut XmlWriter<X>) -> 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<X>(
- heading: &Heading,
- name: &str,
- writer: &mut XmlWriter<X>,
-) -> 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<I>,
-) -> ElementWriter<'a, W>
-where
- I: Into<Attribute<'b>>,
-{
- if let Some(attr) = attr {
- element.with_attribute(attr)
- } else {
- element
- }
-}
-
-impl BinWrite for Dimension {
- type Args<'a> = (usize, u8);
-
- fn write_options<W: Write + Seek>(
- &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<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- D: Iterator<Item = usize>,
- {
- 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<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- D: Iterator<Item = usize>,
- {
- (
- self.name(),
- 0u8,
- 0u8,
- 0u8,
- 2u32,
- data_indexes.next().unwrap() as u32,
- 0u32,
- )
- .write_le(writer)
- }
-}
-
-impl Group {
- fn write_le<D, W>(&self, writer: &mut W, data_indexes: &mut D) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- D: Iterator<Item = usize>,
- {
- (
- 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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &self,
- writer: &mut W,
- endian: binrw::Endian,
- args: Self::Args<'_>,
- ) -> binrw::BinResult<()> {
- (self.0 as u8).write_options(writer, endian, args)
- }
-}
-
-struct SpvString<T>(T);
-impl<'a> SpvString<&'a str> {
- fn optional(s: &'a Option<String>) -> Self {
- Self(s.as_ref().map_or("", |s| s.as_str()))
- }
-}
-impl<T> BinWrite for SpvString<T>
-where
- T: AsRef<str>,
-{
- type Args<'a> = ();
-
- fn write_options<W: Write + Seek>(
- &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<Show>) -> u8 {
- match this {
- None => 0,
- Some(Show::Value) => 1,
- Some(Show::Label) => 2,
- Some(Show::Both) => 3,
- }
- }
-}
-
-struct Count(u64);
-
-impl Count {
- fn new<W>(writer: &mut W) -> binrw::BinResult<Self>
- where
- W: Write + Seek,
- {
- 0u32.write_le(writer)?;
- Ok(Self(writer.stream_position()?))
- }
-
- fn finish<W>(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<W>(self, writer: &mut W) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- {
- self.finish(writer, Endian::Little)
- }
-
- fn finish_be32<W>(self, writer: &mut W) -> binrw::BinResult<()>
- where
- W: Write + Seek,
- {
- self.finish(writer, Endian::Big)
- }
-}
-
-struct Counted<T> {
- inner: T,
- endian: Option<Endian>,
-}
-
-impl<T> Counted<T> {
- fn new(inner: T) -> Self {
- Self {
- inner,
- endian: None,
- }
- }
- fn with_endian(self, endian: Endian) -> Self {
- Self {
- inner: self.inner,
- endian: Some(endian),
- }
- }
-}
-
-impl<T> BinWrite for Counted<T>
-where
- T: BinWrite,
- for<'a> T: BinWrite<Args<'a> = ()>,
-{
- type Args<'a> = T::Args<'a>;
-
- fn write_options<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<f64> {
- 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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<T>(Option<T>);
-
-impl<T> BinWrite for Optional<T>
-where
- T: BinWrite,
-{
- type Args<'a> = T::Args<'a>;
-
- fn write_options<W: Write + Seek>(
- &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<Box<ValueStyle>>,
- 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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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<W: Write + Seek>(
- &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(())
- }
-}
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<PathBuf>,
-
- /// 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<usize>,
-
- /// 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<TextLine>,
-}
-
-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<Stroke> 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<Lines, char>);
-
-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<Lines> for BoxChars {
- type Output = char;
-
- fn index(&self, lines: Lines) -> &Self::Output {
- &self.0[lines]
- }
-}
-
-static ASCII_BOX: LazyLock<BoxChars> = 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<BoxChars> = 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<File>,
- renderer: TextRenderer,
-}
-
-impl TextDriver {
- pub fn new(config: &TextConfig) -> std::io::Result<TextDriver> {
- 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<W>(&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<W>(&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<Item = (usize, BreakOpportunity)> + Clone + 'a,
-{
- text: &'a str,
- max_width: usize,
- indexes: Range<usize>,
- width: usize,
- saved: Option<(usize, BreakOpportunity)>,
- breaks: B,
- trailing_newlines: usize,
-}
-
-impl<'a, B> Iterator for LineBreaks<'a, B>
-where
- B: Iterator<Item = (usize, BreakOpportunity)> + Clone + 'a,
-{
- type Item = &'a str;
-
- fn next(&mut self) -> Option<Self::Item> {
- 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<Item = (usize, BreakOpportunity)> + 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<Item>) {
- let _ = self.renderer.render(item, &mut FmtAdapter(&mut self.file));
- }
-}
-
-struct FmtAdapter<W>(W);
-
-impl<W> FmtWrite for FmtAdapter<W>
-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<Extreme, usize> {
- 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<Axis2, [BorderStyle; 2]>) {
- 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<Axis2, [usize; 2]>,
- 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::<Vec<_>>();
- 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"]);
- }
-}
+++ /dev/null
-// 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 <http://www.gnu.org/licenses/>.
-
-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<F>(&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<usize>,
-
- /// Byte offests.
- offsets: Range<usize>,
-}
-
-/// 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<Self::Item> {
- 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<usize>,
- clip: &Range<usize>,
-) -> 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::<Emphasis>() {
- for uppercase in all::<Emphasis>() {
- 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::<Emphasis>() {
- for uppercase in all::<Emphasis>() {
- 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::<Emphasis>() {
- for hiragana in all::<Emphasis>() {
- 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::<Emphasis>() {
- for hiragana in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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::<Emphasis>() {
- for top in all::<Emphasis>() {
- 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:?}"
- );
- }
- }
- }
-}
data::cases_to_output,
output::{
Details, Item, Text,
- driver::{Config, Driver},
+ drivers::{Config, Driver},
pivot::PivotTable,
},
sys::{
data::cases_to_output,
output::{
Details, Item, Text,
- driver::{Config, Driver},
+ drivers::{Config, Driver},
pivot::PivotTable,
},
pc::PcFile,
data::cases_to_output,
output::{
Details, Item, Text,
- driver::{Config, Driver},
+ drivers::{Config, Driver},
pivot::PivotTable,
},
por::PortableFile,
dictionary::{CategoryLabels, Dictionary, MultipleResponseType},
format::{DisplayPlain, Format},
identifier::Identifier,
- output::spv::Zeros,
+ output::drivers::spv::Zeros,
sys::{
ProductVersion,
encoding::codepage_from_encoding,