html driver compiles
authorBen Pfaff <blp@cs.stanford.edu>
Thu, 1 May 2025 00:41:39 +0000 (17:41 -0700)
committerBen Pfaff <blp@cs.stanford.edu>
Thu, 1 May 2025 00:41:39 +0000 (17:41 -0700)
rust/pspp/src/output/cairo/fsm.rs
rust/pspp/src/output/html.rs [new file with mode: 0644]
rust/pspp/src/output/mod.rs
rust/pspp/src/output/pivot/mod.rs
rust/pspp/src/output/render.rs
rust/pspp/src/output/table.rs
rust/pspp/src/output/text.rs

index 7355f6e9a2829cf66f4fdca0b48079bd25d9cb67..e35b7c95b165fcef6ffb50dc58daecdec47c4617 100644 (file)
@@ -12,7 +12,8 @@ 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, DrawCell, Extreme, Pager, Params};
+use crate::output::render::{Device, Extreme, Pager, Params};
+use crate::output::table::DrawCell;
 use crate::output::{pivot::Color, table::Content};
 use crate::output::{Details, Item};
 
diff --git a/rust/pspp/src/output/html.rs b/rust/pspp/src/output/html.rs
new file mode 100644 (file)
index 0000000..e1fda2a
--- /dev/null
@@ -0,0 +1,431 @@
+use std::{
+    borrow::Cow,
+    fmt::{Display, Write as _},
+    io::Write,
+    sync::Arc,
+};
+
+use smallstr::SmallString;
+
+use crate::output::{
+    driver::Driver,
+    pivot::{Axis2, BorderStyle, Color, Coord2, HorzAlign, PivotTable, Rect2, Stroke, VertAlign},
+    table::{DrawCell, Table},
+    Details, Item,
+};
+
+pub struct HtmlRenderer<W> {
+    writer: W,
+    fg: Color,
+    bg: Color,
+}
+
+impl Stroke {
+    fn as_css(&self) -> Option<&'static str> {
+        match self {
+            Stroke::None => None,
+            Stroke::Solid => Some("solid"),
+            Stroke::Dashed => Some("dashed"),
+            Stroke::Thick => Some("thick solid"),
+            Stroke::Thin => Some("thin solid"),
+            Stroke::Double => Some("double"),
+        }
+    }
+}
+
+impl<W> HtmlRenderer<W>
+where
+    W: Write,
+{
+    pub fn new(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 (index, layer_indexes) in pivot_table.layers(true).enumerate() {
+            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>")?;
+                if let Some(caption) = &output.caption {}
+                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)
+            );
+        }
+
+        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,
+                r#" style="{}""#,
+                style.trim_end_matches("; ")
+            )?;
+        }
+
+        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("&nbsp;")
+                        .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("&nbsp;")
+                        .with_newline("<br>")
+                )?;
+            }
+            write!(&mut self.writer, "</sup>")?;
+        }
+
+        write!(&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
+}
+th { font-weight: normal }
+a:link {
+  color: #1f00ff;
+}
+a:visited {
+  color: #9900dd;
+}
+a:active {
+  color: red;
+}
+</style>
+</head>
+<body>
+"#;
+
+impl<W> Driver for HtmlRenderer<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,
+}
+
+impl<'a> Escape<'a> {
+    fn new(string: &'a str) -> Self {
+        Self {
+            string,
+            space: " ",
+            newline: "\n",
+        }
+    }
+    fn with_space(self, space: &'static str) -> Self {
+        Self { space, ..self }
+    }
+    fn with_newline(self, newline: &'static str) -> Self {
+        Self { newline, ..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("&amp;")?,
+                '<' => f.write_str("&lt;")?,
+                '>' => f.write_str("&gt;")?,
+                '"' => f.write_str("&quot;")?,
+                _ => f.write_char(c)?,
+            }
+        }
+        Ok(())
+    }
+}
index 7f23f8aeb41297054346de84e5315f35dc231028..7f23a50cb9b5d58def685519eef6a22e423603e1 100644 (file)
@@ -10,6 +10,7 @@ use self::pivot::Value;
 pub mod cairo;
 pub mod csv;
 pub mod driver;
+pub mod html;
 pub mod page;
 pub mod pivot;
 pub mod render;
index c00f2cb574adc9575b07c54c9473bf08770b66d3..352bc9081c9db6ca4ebe3166b1a48f3665730801 100644 (file)
@@ -872,7 +872,7 @@ impl Color {
     pub const BLUE: Color = Color::new(0, 0, 255);
     pub const TRANSPARENT: Color = Color::new(0, 0, 0).with_alpha(0);
 
-    const fn new(r: u8, g: u8, b: u8) -> Self {
+    pub const fn new(r: u8, g: u8, b: u8) -> Self {
         Self {
             alpha: 255,
             r,
@@ -881,18 +881,18 @@ impl Color {
         }
     }
 
-    const fn with_alpha(self, alpha: u8) -> Self {
+    pub const fn with_alpha(self, alpha: u8) -> Self {
         Self { alpha, ..self }
     }
+
+    pub fn display_css(&self) -> DisplayCss {
+        DisplayCss(*self)
+    }
 }
 
 impl Debug for Color {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        let Color { alpha, r, g, b } = *self;
-        match alpha {
-            255 => write!(f, "#{r:02x}{g:02x}{b:02x}"),
-            _ => write!(f, "rgb({r}, {g}, {b}, {:.2})", alpha as f64 / 255.0),
-        }
+        write!(f, "{}", self.display_css())
     }
 }
 
@@ -951,6 +951,18 @@ impl<'de> Deserialize<'de> for Color {
     }
 }
 
+pub struct DisplayCss(Color);
+
+impl Display for DisplayCss {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let Color { alpha, r, g, b } = self.0;
+        match alpha {
+            255 => write!(f, "#{r:02x}{g:02x}{b:02x}"),
+            _ => write!(f, "rgb({r}, {g}, {b}, {:.2})", alpha as f64 / 255.0),
+        }
+    }
+}
+
 #[derive(Copy, Clone, Debug, Deserialize)]
 pub struct BorderStyle {
     #[serde(rename = "@borderStyleType")]
@@ -1695,6 +1707,10 @@ impl<'a> DisplayValue<'a> {
         self.subscripts.iter().map(String::as_str)
     }
 
+    pub fn has_subscripts(&self) -> bool {
+        !self.subscripts.is_empty()
+    }
+
     pub fn footnotes(&self) -> impl Iterator<Item = DisplayMarker<'_>> {
         self.footnotes
             .iter()
@@ -1702,6 +1718,10 @@ impl<'a> DisplayValue<'a> {
             .map(|f| f.display_marker(self.options))
     }
 
+    pub fn has_footnotes(&self) -> bool {
+        !self.footnotes().next().is_some()
+    }
+
     pub fn without_suffixes(self) -> Self {
         Self {
             subscripts: &[],
index 7d9e80cb62d7ba06353cb8c0bf34ccd1a0b5afe4..6e72ddb74dd7df919a7f7b30f796e25890abf80a 100644 (file)
@@ -9,13 +9,11 @@ use itertools::interleave;
 use num::Integer;
 use smallvec::SmallVec;
 
-use crate::output::pivot::{DisplayValue, HorzAlign, VertAlign};
+use crate::output::pivot::VertAlign;
+use crate::output::table::DrawCell;
 
-use super::pivot::{
-    AreaStyle, Axis2, BorderStyle, Coord2, Footnote, Look, PivotTable, Rect2, Stroke, ValueInner,
-    ValueOptions,
-};
-use super::table::{CellInner, Content, Table};
+use super::pivot::{Axis2, BorderStyle, Coord2, Look, PivotTable, Rect2, Stroke};
+use super::table::{Content, Table};
 
 /// Parameters for rendering a table_item to a device.
 ///
@@ -153,53 +151,6 @@ pub trait Device {
     fn scale(&mut self, factor: f64);
 }
 
-pub struct DrawCell<'a> {
-    pub rotate: bool,
-    pub inner: &'a ValueInner,
-    pub style: &'a AreaStyle,
-    pub subscripts: &'a [String],
-    pub footnotes: &'a [Arc<Footnote>],
-    pub value_options: &'a ValueOptions,
-}
-
-impl<'a> DrawCell<'a> {
-    fn new(inner: &'a CellInner, table: &'a Table) -> Self {
-        let default_area_style = &table.areas[inner.area];
-        let (style, subscripts, footnotes) = if let Some(styling) = &inner.value.styling {
-            (
-                styling.style.as_ref().unwrap_or(default_area_style),
-                styling.subscripts.as_slice(),
-                styling.footnotes.as_slice(),
-            )
-        } else {
-            (default_area_style, [].as_slice(), [].as_slice())
-        };
-        Self {
-            rotate: inner.rotate,
-            inner: &inner.value.inner,
-            style,
-            subscripts,
-            footnotes,
-            value_options: &table.value_options,
-        }
-    }
-
-    pub fn display(&self) -> DisplayValue<'a> {
-        self.inner
-            .display(self.value_options)
-            .with_font_style(&self.style.font_style)
-            .with_subscripts(self.subscripts)
-            .with_footnotes(self.footnotes)
-    }
-
-    pub fn horz_align(&self, display: &DisplayValue) -> HorzAlign {
-        self.style
-            .cell_style
-            .horz_align
-            .unwrap_or_else(|| HorzAlign::for_mixed(display.var_type()))
-    }
-}
-
 /// A layout for rendering a specific table on a specific device.
 ///
 /// May represent the layout of an entire table presented to [Pager::new], or a
index 797e72fad316d0b463e140eea216033ae2188696..02441b4ab125c46de93618cc72f9b53379efd3e7 100644 (file)
@@ -15,7 +15,7 @@ use std::{ops::Range, sync::Arc};
 use enum_map::{enum_map, EnumMap};
 use ndarray::{Array, Array2};
 
-use crate::output::pivot::Coord2;
+use crate::output::pivot::{Coord2, DisplayValue, Footnote, HorzAlign, ValueInner};
 
 use super::pivot::{
     Area, AreaStyle, Axis2, Border, BorderStyle, HeadingRegion, Rect2, Value, ValueOptions,
@@ -361,3 +361,50 @@ impl<'a> Iterator for Cells<'a> {
         Some(this)
     }
 }
+
+pub struct DrawCell<'a> {
+    pub rotate: bool,
+    pub inner: &'a ValueInner,
+    pub style: &'a AreaStyle,
+    pub subscripts: &'a [String],
+    pub footnotes: &'a [Arc<Footnote>],
+    pub value_options: &'a ValueOptions,
+}
+
+impl<'a> DrawCell<'a> {
+    pub fn new(inner: &'a CellInner, table: &'a Table) -> Self {
+        let default_area_style = &table.areas[inner.area];
+        let (style, subscripts, footnotes) = if let Some(styling) = &inner.value.styling {
+            (
+                styling.style.as_ref().unwrap_or(default_area_style),
+                styling.subscripts.as_slice(),
+                styling.footnotes.as_slice(),
+            )
+        } else {
+            (default_area_style, [].as_slice(), [].as_slice())
+        };
+        Self {
+            rotate: inner.rotate,
+            inner: &inner.value.inner,
+            style,
+            subscripts,
+            footnotes,
+            value_options: &table.value_options,
+        }
+    }
+
+    pub fn display(&self) -> DisplayValue<'a> {
+        self.inner
+            .display(self.value_options)
+            .with_font_style(&self.style.font_style)
+            .with_subscripts(self.subscripts)
+            .with_footnotes(self.footnotes)
+    }
+
+    pub fn horz_align(&self, display: &DisplayValue) -> HorzAlign {
+        self.style
+            .cell_style
+            .horz_align
+            .unwrap_or_else(|| HorzAlign::for_mixed(display.var_type()))
+    }
+}
index 3a2df088b32b54908dfea72a740d561bfbd0ecec..098214c675d5bac9599293ad158c087ffa082bf0 100644 (file)
@@ -11,12 +11,12 @@ use enum_map::{enum_map, Enum, EnumMap};
 use unicode_linebreak::{linebreaks, BreakOpportunity};
 use unicode_width::UnicodeWidthStr;
 
-use crate::output::{render::Extreme, text_line::Emphasis};
+use crate::output::{render::Extreme, table::DrawCell, text_line::Emphasis};
 
 use super::{
     driver::Driver,
     pivot::{Axis2, BorderStyle, Coord2, HorzAlign, PivotTable, Rect2, Stroke},
-    render::{Device, DrawCell, Pager, Params},
+    render::{Device, Pager, Params},
     table::Content,
     text_line::{clip_text, TextLine},
     Details, Item,
@@ -319,11 +319,8 @@ impl TextRenderer {
             }
 
             let mut pager = Pager::new(self, table, Some(layer_indexes.as_slice()));
-            dbg!();
             while pager.has_next(self) {
-                dbg!();
                 pager.draw_next(self, usize::MAX);
-                dbg!();
                 output.append(&mut self.lines);
             }
         }