diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9458df38b..f8a148af1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,14 +16,17 @@ The release run heads these entries with the version and opens a fresh
## Unreleased
-- A cell of an ods file takes a style: `Sheet::set_cell_style` and the
- `setCellStyle` op write the fill, the horizontal alignment, and bold,
+- A cell of an ods or xlsx file takes a style: `Sheet::set_cell_style` and
+ the `setCellStyle` op write the fill, the horizontal alignment, and bold,
italic, underline, strikethrough, colour and size.
- **Fix**: in an ods file, a value written into a covered cell landed outside
the merge, and one written into an empty cell right of a merge failed. The
first now refuses, and the second lands in its cell.
+- **Fix**: an xlsx column in a gap between `
` ranges took the width and
+ the style of the next range. It now has neither.
+
- **Fix**: many xlsx fills showed a wrong colour. A cell now shows its fill,
theme colours and tints, italic, underline, strikethrough and every
alignment.
diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md
index eebac7a6c..f4ba83a38 100644
--- a/docs/design/spreadsheet-editing.md
+++ b/docs/design/spreadsheet-editing.md
@@ -195,7 +195,8 @@ separate scripts. The coordinates are the ones an op names, never a DOM index.
## Cell formatting
-Status: `.ods` writes a cell style. The steps land as a stack, in this order:
+Status: `.ods` and `.xlsx` write a cell style. The steps land as a stack, in
+this order:
1. The xlsx reader reads what the writer writes: a solid fill from `fgColor`,
theme colours with their `tint`, italic, underline and strikethrough, and
diff --git a/src/odr/internal/odf/odf_style.cpp b/src/odr/internal/odf/odf_style.cpp
index b9e3b62c9..272174506 100644
--- a/src/odr/internal/odf/odf_style.cpp
+++ b/src/odr/internal/odf/odf_style.cpp
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#include
@@ -749,19 +750,11 @@ std::string color_value(const Color &color) {
return fmt::format("#{:06x}", color.rgb());
}
-void set_attribute(pugi::xml_node node, const char *name, const char *value) {
- pugi::xml_attribute attribute = node.attribute(name);
- if (!attribute) {
- attribute = node.append_attribute(name);
- }
- attribute.set_value(value);
-}
-
/// Writes the set fields of @p style as attributes of @p properties, the
/// asian and complex variants beside each western one.
void write_text_properties(pugi::xml_node properties, const TextStyle &style) {
const auto set = [&](const char *name, const std::string &value) {
- set_attribute(properties, name, value.c_str());
+ xml::set_attribute(properties, name, value.c_str());
};
if (style.font_size.has_value()) {
@@ -874,7 +867,7 @@ std::string StyleRegistry::create_cell_style(pugi::xml_node automatic_styles,
if (base_node &&
std::strcmp(base_node.parent().name(), "office:automatic-styles") == 0) {
node = automatic_styles.append_copy(base_node);
- set_attribute(node, "style:name", name.c_str());
+ xml::set_attribute(node, "style:name", name.c_str());
} else {
node = automatic_styles.append_child("style:style");
node.append_attribute("style:name").set_value(name.c_str());
@@ -885,15 +878,16 @@ std::string StyleRegistry::create_cell_style(pugi::xml_node automatic_styles,
}
if (cell.background_color.has_value()) {
- set_attribute(properties_of(node, "style:table-cell-properties"),
- "fo:background-color",
- color_value(*cell.background_color).c_str());
+ xml::set_attribute(properties_of(node, "style:table-cell-properties"),
+ "fo:background-color",
+ color_value(*cell.background_color).c_str());
}
if (cell.horizontal_align.has_value()) {
- set_attribute(properties_of(node, "style:table-cell-properties"),
- "style:text-align-source", "fix");
- set_attribute(properties_of(node, "style:paragraph-properties"),
- "fo:text-align", text_align_value(*cell.horizontal_align));
+ xml::set_attribute(properties_of(node, "style:table-cell-properties"),
+ "style:text-align-source", "fix");
+ xml::set_attribute(properties_of(node, "style:paragraph-properties"),
+ "fo:text-align",
+ text_align_value(*cell.horizontal_align));
}
TextStyle text_properties = text;
text_properties.background_color.reset();
diff --git a/src/odr/internal/ooxml/spreadsheet/AGENTS.md b/src/odr/internal/ooxml/spreadsheet/AGENTS.md
index 1308472ef..01059e325 100644
--- a/src/odr/internal/ooxml/spreadsheet/AGENTS.md
+++ b/src/odr/internal/ooxml/spreadsheet/AGENTS.md
@@ -7,7 +7,7 @@ The design of the xlsx module. The feature checklist is in
Scope: read `xl/workbook.xml`, its sheets, the shared strings and the drawings
into the abstract model, one table per sheet. Cell styles resolve from
-`xl/styles.xml`. Write a cell value, and save.
+`xl/styles.xml`. Write a cell value and a cell style, and save.
## Design decisions
@@ -60,6 +60,15 @@ their ids and stop being reachable. A shared string is never written back into
it, so the cell becomes `t="inlineStr"`. A covered cell, a cell holding an
`f`, and a date, time or error value throw `UnsupportedOperation`.
+**A cell style is a new `xf`, never an edit of the one the cell names.**
+`sheet_set_cell_style` starts from the cell's `s`, else its row's where the
+row states `customFormat`, else its column's `style`. `create_cell_format`
+copies that `xf` and its `font`, applies the delta, and appends a `font`, a
+`fill` and an `xf` only where no equal one exists, with `applyFont`,
+`applyFill` and `applyAlignment` set. A `font` keeps the child order of
+`CT_Font`. An empty `styleSheet` first gets the entries every `xf` needs.
+`styles.xml` is a written part, so every save writes it from its dom.
+
A position the file states no `c` for is stated by `insert_cell`: the `c` goes
into its row in column order, a missing `row` into `sheetData` in row order,
and `dimension` widens around it. The cell map is keyed by position, so an
diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp
index ee8c01923..99801113a 100644
--- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp
+++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp
@@ -32,7 +32,7 @@ namespace odr::internal::ooxml::spreadsheet {
namespace {
std::unique_ptr
-create_element_adapter(const Document &document, ElementRegistry ®istry);
+create_element_adapter(Document &document, ElementRegistry ®istry);
/// The `workbook` `calcPr`, appended where it is missing. ECMA-376 18.2.27
/// orders the children, so a new one goes before the first that must follow it.
@@ -60,7 +60,9 @@ Document::Document(std::shared_ptr files)
const AbsPath workbook_path("/xl/workbook.xml");
const auto [workbook_xml, workbook_relations] = parse_xml_(workbook_path);
m_written_parts.push_back(workbook_path);
- const auto [styles_xml, _] = parse_xml_(AbsPath("/xl/styles.xml"));
+ const AbsPath styles_path("/xl/styles.xml");
+ const auto [styles_xml, _] = parse_xml_(styles_path);
+ m_written_parts.push_back(styles_path);
for (pugi::xml_node sheet_node :
workbook_xml.document_element().child("sheets").children("sheet")) {
@@ -112,6 +114,8 @@ const StyleRegistry &Document::style_registry() const {
return m_style_registry;
}
+StyleRegistry &Document::style_registry() { return m_style_registry; }
+
bool Document::is_editable() const noexcept { return true; }
bool Document::is_savable(const bool encrypted) const noexcept {
@@ -184,7 +188,7 @@ using AdapterBase = internal::RegistryElementAdapter<
class ElementAdapter final : public AdapterBase {
public:
- ElementAdapter(const Document &document, ElementRegistry ®istry)
+ ElementAdapter(Document &document, ElementRegistry ®istry)
: AdapterBase(registry), m_document(&document) {}
[[nodiscard]] std::string
@@ -347,6 +351,52 @@ class ElementAdapter final : public AdapterBase {
}
return result;
}
+ /// [ECMA-376] 18.3.1.4: a `c` without `s` shows its row's `s` where the
+ /// row states `customFormat`, else its column's `style`.
+ void sheet_set_cell_style(const ElementIdentifier element_id,
+ const std::uint32_t column, const std::uint32_t row,
+ const TableCellStyle &cell_style,
+ const TextStyle &text_style) const override {
+ const ElementRegistry::Sheet &sheet =
+ m_registry->sheet_element_at(element_id);
+ const ElementRegistry::Sheet::Cell *cell = sheet.cell(column, row);
+
+ ElementIdentifier cell_id = null_element_id;
+ if (cell == nullptr) {
+ cell_id = insert_cell(element_id, column, row);
+ } else {
+ cell_id = cell->element_id;
+ if (m_registry->sheet_cell_element_at(cell_id).is_covered) {
+ throw UnsupportedOperation();
+ }
+ }
+
+ pugi::xml_node node = get_node(cell_id);
+ std::uint32_t base = 0;
+ if (const pugi::xml_attribute style = node.attribute("s")) {
+ base = style.as_uint();
+ } else if (const pugi::xml_node row_node = node.parent();
+ row_node.attribute("customFormat").as_bool()) {
+ base = row_node.attribute("s").as_uint();
+ } else {
+ base = m_registry->sheet_element_at(element_id)
+ .column_node(column)
+ .attribute("style")
+ .as_uint();
+ }
+
+ const std::uint32_t format =
+ m_document->style_registry().create_cell_format(base, cell_style,
+ text_style);
+ pugi::xml_attribute attribute = node.attribute("s");
+ if (!attribute) {
+ const pugi::xml_attribute reference = node.attribute("r");
+ attribute = reference ? node.insert_attribute_after("s", reference)
+ : node.prepend_attribute("s");
+ }
+ attribute.set_value(format);
+ }
+
[[nodiscard]] TableCellStyle
sheet_cell_style(const ElementIdentifier element_id,
const std::uint32_t column,
@@ -593,7 +643,7 @@ class ElementAdapter final : public AdapterBase {
}
private:
- const Document *m_document{nullptr};
+ Document *m_document{nullptr};
[[nodiscard]] pugi::xml_node
get_node(const ElementIdentifier element_id) const {
@@ -763,7 +813,7 @@ class ElementAdapter final : public AdapterBase {
};
std::unique_ptr
-create_element_adapter(const Document &document, ElementRegistry ®istry) {
+create_element_adapter(Document &document, ElementRegistry ®istry) {
return std::make_unique(document, registry);
}
diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.hpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.hpp
index a9059847e..999a7cd7b 100644
--- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.hpp
+++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.hpp
@@ -21,6 +21,7 @@ class Document final : public internal::Document {
[[nodiscard]] const ElementRegistry &element_registry() const;
[[nodiscard]] const StyleRegistry &style_registry() const;
+ StyleRegistry &style_registry();
[[nodiscard]] bool is_editable() const noexcept override;
[[nodiscard]] bool is_savable(bool encrypted) const noexcept override;
diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_element_registry.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_element_registry.cpp
index d639ed2ef..b55a335e7 100644
--- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_element_registry.cpp
+++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_element_registry.cpp
@@ -71,10 +71,10 @@ void ElementRegistry::append_sheet_cell(const ElementIdentifier sheet_id,
element_at(cell_id).parent_id = sheet_id;
}
-void ElementRegistry::Sheet::register_column(
- [[maybe_unused]] const std::uint32_t column_min,
- const std::uint32_t column_max, const pugi::xml_node element) {
- columns[column_max] = {.node = element};
+void ElementRegistry::Sheet::register_column(const std::uint32_t column_min,
+ const std::uint32_t column_max,
+ const pugi::xml_node element) {
+ columns[column_max] = {.min = column_min, .node = element};
}
void ElementRegistry::Sheet::register_row(const std::uint32_t row,
@@ -93,8 +93,10 @@ void ElementRegistry::Sheet::register_cell(const std::uint32_t column,
const ElementRegistry::Sheet::Column *
ElementRegistry::Sheet::column(const std::uint32_t column) const {
+ // the ranges leave gaps, so the one ending at or after `column` may start
+ // past it
if (const auto it = util::map::lookup_greater_or_equals(columns, column);
- it != std::end(columns)) {
+ it != std::end(columns) && it->second.min <= column) {
return &it->second;
}
return nullptr;
diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_element_registry.hpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_element_registry.hpp
index 5833d5a2b..164f9c1b4 100644
--- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_element_registry.hpp
+++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_element_registry.hpp
@@ -37,6 +37,7 @@ class ElementRegistry final
struct Sheet final {
struct Column final {
+ std::uint32_t min{0};
pugi::xml_node node;
};
diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp
index fc3e74449..8c6de19c5 100644
--- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp
+++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp
@@ -2,10 +2,17 @@
#include
#include
+#include
+#include
#include
+#include
+#include
+#include
#include
+#include
+
namespace odr::internal::ooxml::spreadsheet {
namespace {
@@ -68,12 +75,118 @@ bool read_toggle(const pugi::xml_node node) {
return node && node.attribute("val").as_bool(true);
}
+/// The child @p name of @p parent, made at its place in @p order.
+pugi::xml_node ordered_child(pugi::xml_node parent, const char *name,
+ const std::span order) {
+ if (const pugi::xml_node existing = parent.child(name)) {
+ return existing;
+ }
+ return insert_in_sequence(parent, name, order);
+}
+
+/// The @p name child of `styleSheet`, made in the order [ECMA-376] 18.8.39
+/// gives them where it is missing.
+pugi::xml_node collection_of(pugi::xml_node root, const char *name) {
+ static constexpr std::array order{
+ "numFmts", "fonts", "fills", "borders",
+ "cellStyleXfs", "cellXfs", "cellStyles", "dxfs",
+ "tableStyles", "colors", "extLst"};
+ return ordered_child(root, name, order);
+}
+
+std::string argb_of(const Color &color) { return "FF" + hex_color(color); }
+
+/// [ECMA-376] 18.8.22 `CT_Font` is a sequence.
+constexpr std::array font_order{
+ "b", "i", "strike", "condense", "extend", "outline", "shadow", "u",
+ "vertAlign", "sz", "color", "name", "family", "charset", "scheme"};
+
+/// `val` is dropped for on, as Excel writes it, and is `0` for off.
+void set_toggle(pugi::xml_node font, const char *name, const bool on) {
+ pugi::xml_node node = ordered_child(font, name, font_order);
+ node.remove_attribute("val");
+ if (!on) {
+ node.append_attribute("val").set_value("0");
+ }
+}
+
+std::string serialized(const pugi::xml_node node) {
+ std::ostringstream out;
+ node.print(out, "", pugi::format_raw);
+ return out.str();
+}
+
+/// The index of the child of @p collection equal to @p node, which is
+/// appended where none is, with the `count` kept.
+std::uint32_t intern(pugi::xml_node collection, const pugi::xml_node node) {
+ const std::string wanted = serialized(node);
+ std::uint32_t index = 0;
+ for (const pugi::xml_node child : collection.children()) {
+ if (serialized(child) == wanted) {
+ return index;
+ }
+ ++index;
+ }
+ collection.append_copy(node);
+ xml::set_attribute(collection, "count", std::to_string(index + 1).c_str());
+ return index;
+}
+
+/// What an empty `styleSheet` lacks and every `xf` needs a first entry of.
+void state_defaults(pugi::xml_node root) {
+ if (pugi::xml_node fonts = collection_of(root, "fonts");
+ !fonts.first_child()) {
+ pugi::xml_node font = fonts.append_child("font");
+ font.append_child("sz").append_attribute("val").set_value("11");
+ font.append_child("name").append_attribute("val").set_value("Calibri");
+ xml::set_attribute(fonts, "count", "1");
+ }
+ if (pugi::xml_node fills = collection_of(root, "fills");
+ !fills.first_child()) {
+ // 18.8.21: the first two are reserved
+ fills.append_child("fill")
+ .append_child("patternFill")
+ .append_attribute("patternType")
+ .set_value("none");
+ fills.append_child("fill")
+ .append_child("patternFill")
+ .append_attribute("patternType")
+ .set_value("gray125");
+ xml::set_attribute(fills, "count", "2");
+ }
+ if (pugi::xml_node borders = collection_of(root, "borders");
+ !borders.first_child()) {
+ borders.append_child("border");
+ xml::set_attribute(borders, "count", "1");
+ }
+ const auto default_xf = [](pugi::xml_node xf) {
+ xf.append_attribute("numFmtId").set_value("0");
+ xf.append_attribute("fontId").set_value("0");
+ xf.append_attribute("fillId").set_value("0");
+ xf.append_attribute("borderId").set_value("0");
+ return xf;
+ };
+ if (pugi::xml_node masters = collection_of(root, "cellStyleXfs");
+ !masters.first_child()) {
+ default_xf(masters.append_child("xf"));
+ xml::set_attribute(masters, "count", "1");
+ }
+ if (pugi::xml_node formats = collection_of(root, "cellXfs");
+ !formats.first_child()) {
+ default_xf(formats.append_child("xf"))
+ .append_attribute("xfId")
+ .set_value("0");
+ xml::set_attribute(formats, "count", "1");
+ }
+}
+
} // namespace
StyleRegistry::StyleRegistry() = default;
StyleRegistry::StyleRegistry(const pugi::xml_node styles_root,
- const pugi::xml_node theme_root) {
+ const pugi::xml_node theme_root)
+ : m_styles_root{styles_root} {
const pugi::xml_node scheme =
theme_root.child("a:themeElements").child("a:clrScheme");
for (const char *name : {"a:lt1", "a:dk1", "a:lt2", "a:dk2", "a:accent1",
@@ -222,7 +335,101 @@ void StyleRegistry::resolve_border_(const std::uint32_t i,
result.table_cell_style.border.bottom = side(border.child("bottom"));
}
+std::uint32_t
+StyleRegistry::create_cell_format(const std::uint32_t base,
+ const TableCellStyle &cell_style,
+ const TextStyle &text_style) {
+ state_defaults(m_styles_root);
+ generate_indices_(m_styles_root);
+
+ pugi::xml_document scratch;
+ pugi::xml_node xf = scratch.append_copy(
+ m_cell_formats_index.at(base < m_cell_formats_index.size() ? base : 0));
+
+ if (text_style.font_weight || text_style.font_style ||
+ text_style.font_underline || text_style.font_line_through ||
+ text_style.font_color || text_style.font_size) {
+ const std::uint32_t font_id = xf.attribute("fontId").as_uint();
+ pugi::xml_node font = scratch.append_copy(
+ m_fonts_index.at(font_id < m_fonts_index.size() ? font_id : 0));
+ if (text_style.font_weight) {
+ set_toggle(font, "b", *text_style.font_weight == FontWeight::bold);
+ }
+ if (text_style.font_style) {
+ set_toggle(font, "i", *text_style.font_style == FontStyle::italic);
+ }
+ if (text_style.font_line_through) {
+ set_toggle(font, "strike", *text_style.font_line_through);
+ }
+ if (text_style.font_underline) {
+ pugi::xml_node underline = ordered_child(font, "u", font_order);
+ underline.remove_attribute("val");
+ if (!*text_style.font_underline) {
+ underline.append_attribute("val").set_value("none");
+ }
+ }
+ if (text_style.font_size) {
+ xml::set_attribute(
+ ordered_child(font, "sz", font_order), "val",
+ fmt::format("{:g}", points(*text_style.font_size)).c_str());
+ }
+ if (text_style.font_color) {
+ pugi::xml_node color = ordered_child(font, "color", font_order);
+ color.remove_attributes();
+ color.append_attribute("rgb").set_value(
+ argb_of(*text_style.font_color).c_str());
+ }
+ xml::set_attribute(
+ xf, "fontId",
+ std::to_string(intern(m_styles_root.child("fonts"), font)).c_str());
+ xml::set_attribute(xf, "applyFont", "1");
+ }
+
+ if (cell_style.background_color) {
+ pugi::xml_node fill = scratch.append_child("fill");
+ pugi::xml_node pattern = fill.append_child("patternFill");
+ if (cell_style.background_color->alpha == 0) {
+ pattern.append_attribute("patternType").set_value("none");
+ } else {
+ pattern.append_attribute("patternType").set_value("solid");
+ pattern.append_child("fgColor").append_attribute("rgb").set_value(
+ argb_of(*cell_style.background_color).c_str());
+ pattern.append_child("bgColor").append_attribute("indexed").set_value(
+ "64");
+ }
+ xml::set_attribute(
+ xf, "fillId",
+ std::to_string(intern(m_styles_root.child("fills"), fill)).c_str());
+ xml::set_attribute(xf, "applyFill", "1");
+ }
+
+ if (cell_style.horizontal_align) {
+ pugi::xml_node alignment = xf.child("alignment");
+ if (!alignment) {
+ alignment = xf.prepend_child("alignment");
+ }
+ const char *horizontal = "left";
+ if (*cell_style.horizontal_align == HorizontalAlign::center) {
+ horizontal = "center";
+ } else if (*cell_style.horizontal_align == HorizontalAlign::right) {
+ horizontal = "right";
+ }
+ xml::set_attribute(alignment, "horizontal", horizontal);
+ xml::set_attribute(xf, "applyAlignment", "1");
+ }
+
+ const std::uint32_t result = intern(m_styles_root.child("cellXfs"), xf);
+ generate_indices_(m_styles_root);
+ return result;
+}
+
void StyleRegistry::generate_indices_(const pugi::xml_node styles_root) {
+ m_fonts_index.clear();
+ m_fills_index.clear();
+ m_borders_index.clear();
+ m_cell_masters_index.clear();
+ m_cell_formats_index.clear();
+
for (const pugi::xml_node font : styles_root.child("fonts")) {
m_fonts_index.push_back(font);
}
diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.hpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.hpp
index 528741598..25dda9725 100644
--- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.hpp
+++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.hpp
@@ -2,6 +2,8 @@
#include
+#include
+
#include
#include
@@ -17,7 +19,14 @@ class StyleRegistry final {
[[nodiscard]] ResolvedStyle cell_style(std::uint32_t i) const;
+ /// The `cellXfs` index of @p base with the delta applied. An equal `xf`,
+ /// `font` or `fill` is reused, else one is appended.
+ std::uint32_t create_cell_format(std::uint32_t base,
+ const TableCellStyle &cell_style,
+ const TextStyle &text_style);
+
private:
+ pugi::xml_node m_styles_root;
/// `lt1`, `dk1`, `lt2`, `dk2`, `accent1` to `accent6`, `hlink`, `folHlink`:
/// the order a `theme` index counts in.
std::vector> m_theme_colors;
diff --git a/src/odr/internal/xml/xml_util.cpp b/src/odr/internal/xml/xml_util.cpp
index caf471864..434c5512f 100644
--- a/src/odr/internal/xml/xml_util.cpp
+++ b/src/odr/internal/xml/xml_util.cpp
@@ -89,6 +89,15 @@ pugi::xml_document xml::parse(std::istream &in) {
void xml::check_xml_file(std::istream &in) { std::ignore = parse(in); }
+void xml::set_attribute(pugi::xml_node node, const char *name,
+ const char *value) {
+ pugi::xml_attribute attribute = node.attribute(name);
+ if (!attribute) {
+ attribute = node.append_attribute(name);
+ }
+ attribute.set_value(value);
+}
+
std::string xml::read_declared_encoding(std::istream &in) {
static constexpr std::size_t probe_size = 1024;
static constexpr std::string_view space = " \t\r\n";
diff --git a/src/odr/internal/xml/xml_util.hpp b/src/odr/internal/xml/xml_util.hpp
index 9ad1d9adc..7667a507c 100644
--- a/src/odr/internal/xml/xml_util.hpp
+++ b/src/odr/internal/xml/xml_util.hpp
@@ -7,6 +7,7 @@
namespace pugi {
class xml_document;
+class xml_node;
} // namespace pugi
namespace odr::internal::abstract {
@@ -33,6 +34,9 @@ pugi::xml_document parse(std::istream &);
pugi::xml_document parse(const abstract::File &);
pugi::xml_document parse(const abstract::ReadableFilesystem &, const AbsPath &);
+/// Sets the attribute @p name of @p node, appending it where it is missing.
+void set_attribute(pugi::xml_node node, const char *name, const char *value);
+
/// Throws unless @p in holds a well formed xml document.
void check_xml_file(std::istream &in);
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index f2c243510..6d272478b 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -101,6 +101,7 @@ add_executable(odr_test
"src/internal/ooxml/ooxml_text_style_test.cpp"
"src/internal/ooxml/ooxml_spreadsheet_merge_test.cpp"
"src/internal/ooxml/ooxml_spreadsheet_style_test.cpp"
+ "src/internal/ooxml/ooxml_spreadsheet_style_write_test.cpp"
"src/internal/ooxml/ooxml_spreadsheet_value_test.cpp"
"src/internal/ooxml/ooxml_spreadsheet_write_test.cpp"
"src/internal/ooxml/ooxml_util_test.cpp"
diff --git a/test/src/internal/ooxml/ooxml_spreadsheet_style_write_test.cpp b/test/src/internal/ooxml/ooxml_spreadsheet_style_write_test.cpp
new file mode 100644
index 000000000..d3b00a041
--- /dev/null
+++ b/test/src/internal/ooxml/ooxml_spreadsheet_style_write_test.cpp
@@ -0,0 +1,239 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include
+
+#include
+#include
+#include
+#include
+#include
+
+using namespace odr;
+using namespace odr::test::ooxml;
+
+namespace {
+
+constexpr const char *one_string =
+ R"(a
)";
+
+/// Two cells sharing `xf` 1, which fills them red.
+constexpr const char *two_red =
+ R"()"
+ R"(a)"
+ R"(b)"
+ R"(
)";
+constexpr const char *red_styles =
+ R"()"
+ R"()"
+ R"()"
+ R"()"
+ R"()"
+ R"()"
+ R"()"
+ R"()"
+ R"()"
+ R"()";
+
+TableCellStyle fill(const Color color) {
+ TableCellStyle style;
+ style.background_color = color;
+ return style;
+}
+
+TextStyle bold() {
+ TextStyle style;
+ style.font_weight = FontWeight::bold;
+ return style;
+}
+
+std::optional fill_at(const Sheet &sheet,
+ const std::uint32_t column,
+ const std::uint32_t row) {
+ const std::optional color =
+ sheet.cell_style(column, row).background_color;
+ return color ? std::optional(color->rgb()) : std::nullopt;
+}
+
+/// The style the one run of the cell shows.
+TextStyle text_style_at(const Sheet &sheet, const std::uint32_t column) {
+ return sheet.cell(column, 0).first_child().as_text().style();
+}
+
+Document reopened(const Document &document) {
+ std::ostringstream saved;
+ document.save(saved);
+ return open(File::from_memory(saved.str())).as_document_file().document();
+}
+
+std::string styles_of(const Document &document) {
+ std::ostringstream xml;
+ xml << reopened(document)
+ .as_filesystem()
+ .open("/xl/styles.xml")
+ .stream()
+ ->rdbuf();
+ return xml.str();
+}
+
+std::size_t count(const std::string &text, const std::string &part) {
+ std::size_t result = 0;
+ for (std::size_t at = text.find(part); at != std::string::npos;
+ at = text.find(part, at + 1)) {
+ ++result;
+ }
+ return result;
+}
+
+} // namespace
+
+TEST(OoxmlSpreadsheetStyleWrite,
+ a_fill_survives_a_save_of_an_empty_style_sheet) {
+ const Document document = decode(workbook(one_string));
+
+ first_sheet(document).set_cell_style(0, 0, fill(0xffff00_rgb), {});
+
+ EXPECT_EQ(fill_at(first_sheet(document), 0, 0), 0xffff00u);
+ const Document saved = reopened(document);
+ EXPECT_EQ(fill_at(first_sheet(saved), 0, 0), 0xffff00u);
+ EXPECT_EQ(first_sheet(saved).cell(0, 0).value().text(), "a");
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, a_font_takes_every_text_key) {
+ const Document document = decode(workbook(one_string));
+ TextStyle style;
+ style.font_weight = FontWeight::bold;
+ style.font_style = FontStyle::italic;
+ style.font_underline = true;
+ style.font_line_through = true;
+ style.font_color = 0xcc0000_rgb;
+ style.font_size = Measure("16pt");
+
+ first_sheet(document).set_cell_style(0, 0, {}, style);
+
+ const TextStyle read = text_style_at(first_sheet(reopened(document)), 0);
+ EXPECT_EQ(read.font_weight, FontWeight::bold);
+ EXPECT_EQ(read.font_style, FontStyle::italic);
+ EXPECT_EQ(read.font_underline, true);
+ EXPECT_EQ(read.font_line_through, true);
+ ASSERT_TRUE(read.font_color.has_value());
+ EXPECT_EQ(read.font_color->rgb(), 0xcc0000u);
+ EXPECT_EQ(read.font_size, Measure("16pt"));
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, a_format_shared_with_another_cell_is_copied) {
+ const Document document =
+ decode(workbook(two_red, "", "", "", "", red_styles));
+ const Sheet sheet = first_sheet(document);
+
+ sheet.set_cell_style(0, 0, {}, bold());
+
+ EXPECT_EQ(text_style_at(sheet, 0).font_weight, FontWeight::bold);
+ EXPECT_EQ(fill_at(sheet, 0, 0), 0xff0000u);
+ EXPECT_NE(text_style_at(sheet, 1).font_weight, FontWeight::bold);
+ EXPECT_EQ(fill_at(sheet, 1, 0), 0xff0000u);
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, one_delta_on_one_base_is_one_format) {
+ const Document document =
+ decode(workbook(two_red, "", "", "", "", red_styles));
+ const Sheet sheet = first_sheet(document);
+
+ sheet.set_cell_style(0, 0, fill(0x00ff00_rgb), bold());
+ sheet.set_cell_style(1, 0, fill(0x00ff00_rgb), bold());
+
+ const std::string styles = styles_of(document);
+ EXPECT_EQ(count(styles, ")"), std::string::npos);
+ EXPECT_EQ(count(styles, ""), 2);
+ EXPECT_EQ(count(styles, ""), 4);
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, a_fill_taken_away_is_no_pattern) {
+ const Document document =
+ decode(workbook(two_red, "", "", "", "", red_styles));
+ const Sheet sheet = first_sheet(document);
+
+ sheet.set_cell_style(0, 0, fill(Color(0, 0, 0, 0)), {});
+
+ EXPECT_EQ(fill_at(sheet, 0, 0), std::nullopt);
+ EXPECT_EQ(fill_at(sheet, 1, 0), 0xff0000u);
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, an_alignment_lands_in_the_format) {
+ const Document document = decode(workbook(one_string));
+ TableCellStyle right;
+ right.horizontal_align = HorizontalAlign::right;
+
+ first_sheet(document).set_cell_style(0, 0, right, {});
+
+ EXPECT_EQ(first_sheet(reopened(document)).cell_style(0, 0).horizontal_align,
+ HorizontalAlign::right);
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, a_cell_the_file_does_not_state_is_made) {
+ const Document document = decode(workbook(one_string));
+
+ first_sheet(document).set_cell_style(2, 3, fill(0x0000ff_rgb), {});
+
+ EXPECT_EQ(fill_at(first_sheet(reopened(document)), 2, 3), 0x0000ffu);
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, a_cell_without_a_format_starts_from_its_row) {
+ const Document document = decode(
+ workbook(R"()"
+ R"(a
)",
+ "", "", "", "", red_styles));
+
+ first_sheet(document).set_cell_style(0, 0, {}, bold());
+
+ const Document saved = reopened(document);
+ const Sheet sheet = first_sheet(saved);
+ EXPECT_EQ(fill_at(sheet, 0, 0), 0xff0000u);
+ EXPECT_EQ(text_style_at(sheet, 0).font_weight, FontWeight::bold);
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, a_cell_starts_from_its_own_column_only) {
+ const Document document = decode(
+ workbook(one_string, "", "", "",
+ R"()", red_styles));
+
+ first_sheet(document).set_cell_style(0, 0, {}, bold());
+ first_sheet(document).set_cell_style(2, 0, {}, bold());
+
+ const Document saved = reopened(document);
+ const Sheet sheet = first_sheet(saved);
+ EXPECT_EQ(fill_at(sheet, 0, 0), std::nullopt);
+ EXPECT_EQ(fill_at(sheet, 2, 0), 0xff0000u);
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, a_covered_cell_refuses) {
+ const Document document = decode(workbook(
+ R"(a
)",
+ R"()"));
+
+ EXPECT_THROW(
+ first_sheet(document).set_cell_style(1, 0, fill(0xffff00_rgb), {}),
+ UnsupportedOperation);
+}
+
+TEST(OoxmlSpreadsheetStyleWrite, the_op_carries_the_fill_and_the_text_keys) {
+ const Document document = decode(workbook(one_string));
+
+ document.edit(
+ R"({"version": 2, "ops": [{"op": "setCellStyle", "sheet": 0,)"
+ R"( "column": 0, "row": 0,)"
+ R"( "style": {"fill": "#ffff00", "bold": true, "align": "center"}}]})");
+
+ const Sheet sheet = first_sheet(document);
+ EXPECT_EQ(fill_at(sheet, 0, 0), 0xffff00u);
+ EXPECT_EQ(text_style_at(sheet, 0).font_weight, FontWeight::bold);
+ EXPECT_EQ(sheet.cell_style(0, 0).horizontal_align, HorizontalAlign::center);
+}
diff --git a/test/src/internal/ooxml/ooxml_spreadsheet_test_util.hpp b/test/src/internal/ooxml/ooxml_spreadsheet_test_util.hpp
index 9c7f309e3..8171ab151 100644
--- a/test/src/internal/ooxml/ooxml_spreadsheet_test_util.hpp
+++ b/test/src/internal/ooxml/ooxml_spreadsheet_test_util.hpp
@@ -27,12 +27,13 @@ inline void insert(internal::zip::ZipArchive &zip, const std::string &path,
/// @p sheet_data and which carries @p sheet_extra - ``, say -
/// after it and @p sheet_prefix - `` - before it.
/// @p shared_strings writes a `sharedStrings.xml` where it is given, and
-/// @p workbook_extra follows `` in `workbook.xml`.
+/// @p workbook_extra follows `` in `workbook.xml`, and @p styles is
+/// what `styles.xml` holds.
inline std::shared_ptr
workbook(const std::string &sheet_data, const std::string &sheet_extra = "",
const std::string &shared_strings = "",
const std::string &workbook_extra = "",
- const std::string &sheet_prefix = "") {
+ const std::string &sheet_prefix = "", const std::string &styles = "") {
internal::zip::ZipArchive zip;
insert(
zip, "[Content_Types].xml",
@@ -58,7 +59,8 @@ workbook(const std::string &sheet_data, const std::string &sheet_extra = "",
R"()");
insert(
zip, "xl/styles.xml",
- R"()");
+ R"()" +
+ styles + R"()");
insert(
zip, "xl/worksheets/sheet1.xml",
R"()" +