diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad39b8cf..025da7e78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- **Fix**: many xlsx fills showed a wrong colour. A cell now shows its fill, + theme colours and tints, italic, underline, strikethrough and every + alignment. + - **Fix**: a cell fill disappeared in the dark color scheme. The cell now keeps its fill in a dark version with the same hue, and the text stays readable on it. diff --git a/CMakeLists.txt b/CMakeLists.txt index fa2e18799..abc380201 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -341,6 +341,7 @@ set(ODR_SOURCE_FILES "src/odr/internal/text/text_file.cpp" "src/odr/internal/util/byte_util.cpp" + "src/odr/internal/util/color_util.cpp" "src/odr/internal/util/byte_stream_util.cpp" "src/odr/internal/util/byte_string.cpp" "src/odr/internal/util/file_util.cpp" diff --git a/src/odr/internal/html/common.cpp b/src/odr/internal/html/common.cpp index 5b0bb0fff..bde445133 100644 --- a/src/odr/internal/html/common.cpp +++ b/src/odr/internal/html/common.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -360,49 +361,6 @@ std::string html::color(const Color &color) { namespace { -struct Oklab { - double l{0}; - double a{0}; - double b{0}; -}; - -double to_linear(const std::uint8_t channel) { - const double c = channel / 255.0; - return c <= 0.04045 ? c / 12.92 : std::pow((c + 0.055) / 1.055, 2.4); -} - -double from_linear(const double c) { - return c <= 0.0031308 ? c * 12.92 : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055; -} - -Oklab to_oklab(const Color &color) { - const double r = to_linear(color.red); - const double g = to_linear(color.green); - const double b = to_linear(color.blue); - const double l = - std::cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); - const double m = - std::cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); - const double s = - std::cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); - return {0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, - 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, - 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s}; -} - -/// Linear srgb, possibly outside the gamut. -std::array to_linear_rgb(const Oklab &lab) { - const double l = - std::pow(lab.l + 0.3963377774 * lab.a + 0.2158037573 * lab.b, 3); - const double m = - std::pow(lab.l - 0.1055613458 * lab.a - 0.0638541728 * lab.b, 3); - const double s = - std::pow(lab.l - 0.0894841775 * lab.a - 1.2914855480 * lab.b, 3); - return {4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, - -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, - -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s}; -} - bool in_gamut(const std::array &rgb) { return std::ranges::all_of( rgb, [](const double c) { return c >= -1e-9 && c <= 1 + 1e-9; }); @@ -413,31 +371,34 @@ bool in_gamut(const std::array &rgb) { Color html::dark_fill(const Color &color) { // White lands on the page of `document-dark.css`, black on the lightest // ground its `#e6edf3` text reads on at 4.5:1. - static const Oklab page = to_oklab(Color(0x16, 0x1b, 0x22)); + static const util::color::Oklab page = + util::color::Oklab::from_color(Color(0x16, 0x1b, 0x22)); static constexpr double lightest = 0.5; - const Oklab source = to_oklab(color); + const util::color::Oklab source = util::color::Oklab::from_color(color); const double t = 1 - source.l; - Oklab lab{page.l + t * (lightest - page.l), page.a * (1 - t) + source.a, - page.b * (1 - t) + source.b}; + util::color::Oklab lab{page.l + t * (lightest - page.l), + page.a * (1 - t) + source.a, + page.b * (1 - t) + source.b}; // Less chroma until the color fits, so the hue stays. double low = 0; double high = 1; - if (!in_gamut(to_linear_rgb(lab))) { + if (!in_gamut(lab.to_linear_rgb())) { for (int i = 0; i < 20; ++i) { const double mid = (low + high) / 2; - (in_gamut(to_linear_rgb({lab.l, lab.a * mid, lab.b * mid})) ? low - : high) = mid; + (in_gamut( + util::color::Oklab{lab.l, lab.a * mid, lab.b * mid}.to_linear_rgb()) + ? low + : high) = mid; } lab.a *= low; lab.b *= low; } - const std::array rgb = to_linear_rgb(lab); + const std::array rgb = lab.to_linear_rgb(); const auto channel = [](const double c) { - return static_cast( - std::lround(std::clamp(from_linear(c), 0.0, 1.0) * 255)); + return util::color::to_byte(util::color::linear_to_srgb(c)); }; return {channel(rgb[0]), channel(rgb[1]), channel(rgb[2]), color.alpha}; } diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index fa25d512d..04fd942d8 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -291,12 +292,6 @@ void write_page_links(HtmlWriter &out, const std::vector &links) { } } -/// Clamp a colour component in [0, 1] to an 8-bit channel value. -std::int32_t to255(const double v) { - return static_cast( - std::lround(std::clamp(v, 0.0, 1.0) * 255.0)); -} - /// A PDF device color as CSS `rgb(...)`. Other spaces are already converted at /// extract time; only `unknown` reaches here, falling back to black. std::string device_color_to_css(const pdf::GraphicsState::Color &color) { @@ -305,19 +300,19 @@ std::string device_color_to_css(const pdf::GraphicsState::Color &color) { std::int32_t b = 0; switch (color.space) { case pdf::ColorSpace::device_grey: - r = g = b = to255(color.grey); + r = g = b = util::color::to_byte(color.grey); break; case pdf::ColorSpace::device_rgb: - r = to255(color.rgb[0]); - g = to255(color.rgb[1]); - b = to255(color.rgb[2]); + r = util::color::to_byte(color.rgb[0]); + g = util::color::to_byte(color.rgb[1]); + b = util::color::to_byte(color.rgb[2]); break; case pdf::ColorSpace::device_cmyk: { const std::array rgb = pdf::cmyk_to_rgb( color.cmyk[0], color.cmyk[1], color.cmyk[2], color.cmyk[3]); - r = to255(rgb[0]); - g = to255(rgb[1]); - b = to255(rgb[2]); + r = util::color::to_byte(rgb[0]); + g = util::color::to_byte(rgb[1]); + b = util::color::to_byte(rgb[2]); break; } case pdf::ColorSpace::unknown: @@ -332,7 +327,8 @@ std::string device_color_to_css(const pdf::GraphicsState::Color &color) { /// `rgb(...)`. std::string rgb_to_css(const std::array &rgb) { std::ostringstream s; - s << "rgb(" << to255(rgb[0]) << ',' << to255(rgb[1]) << ',' << to255(rgb[2]) + s << "rgb(" << +util::color::to_byte(rgb[0]) << ',' + << +util::color::to_byte(rgb[1]) << ',' << +util::color::to_byte(rgb[2]) << ')'; return std::move(s).str(); } diff --git a/src/odr/internal/ooxml/ooxml_util.cpp b/src/odr/internal/ooxml/ooxml_util.cpp index 58f26e0c7..6cbe111db 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -209,6 +210,22 @@ ooxml::read_color_attribute(const pugi::xml_attribute attribute) { return {}; } +std::optional +ooxml::read_drawing_rgb_color(const pugi::xml_node parent) { + if (const std::optional color = + read_color_attribute(parent.child("a:srgbClr").attribute("val"))) { + return color; + } + return read_color_attribute(parent.child("a:sysClr").attribute("lastClr")); +} + +Color ooxml::apply_tint(const Color &color, const double tint) { + util::color::Hsl hsl = util::color::Hsl::from_color(color); + hsl.lightness = + tint < 0 ? hsl.lightness * (1 + tint) : hsl.lightness * (1 - tint) + tint; + return hsl.to_color(color.alpha); +} + std::optional ooxml::read_half_point_attribute(const pugi::xml_attribute attribute) { if (!attribute) { diff --git a/src/odr/internal/ooxml/ooxml_util.hpp b/src/odr/internal/ooxml/ooxml_util.hpp index 88c7e668c..e8a47a552 100644 --- a/src/odr/internal/ooxml/ooxml_util.hpp +++ b/src/odr/internal/ooxml/ooxml_util.hpp @@ -51,6 +51,12 @@ double points(const Measure &length); std::optional read_string_attribute(pugi::xml_attribute); std::optional read_color_attribute(pugi::xml_attribute); +/// The `a:srgbClr` of @p parent, else the value its `a:sysClr` last resolved +/// to. [ECMA-376] 20.1.2.3.32, 20.1.2.3.33 +std::optional read_drawing_rgb_color(pugi::xml_node parent); +/// [ECMA-376] 18.8.19: a negative @p tint moves the lightness toward black, a +/// positive one toward white. +Color apply_tint(const Color &color, double tint); std::optional read_half_point_attribute(pugi::xml_attribute); std::optional read_eighth_point_attribute(pugi::xml_attribute); std::optional read_hundredth_point_attribute(pugi::xml_attribute); diff --git a/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp b/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp index 83fae106e..2604e1407 100644 --- a/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp +++ b/src/odr/internal/ooxml/presentation/ooxml_presentation_style.cpp @@ -13,16 +13,6 @@ namespace odr::internal::ooxml::presentation { namespace { -/// A literal colour, or a system colour that names the value it last resolved -/// to. [ECMA-376] 20.1.2.3.32, 20.1.2.3.33 -std::optional read_theme_color_(const pugi::xml_node slot) { - if (const std::optional color = - read_color_attribute(slot.child("a:srgbClr").attribute("val"))) { - return color; - } - return read_color_attribute(slot.child("a:sysClr").attribute("lastClr")); -} - /// `a:spcPct` in thousandths of a percent, or `a:spcPts` in hundredths of a /// point. [ECMA-376] 21.1.2.2.12 std::optional read_line_spacing(const pugi::xml_node node) { @@ -58,7 +48,7 @@ namespace odr::internal::ooxml { presentation::ColorScheme::ColorScheme(const pugi::xml_node color_scheme, const pugi::xml_node color_map) { for (const pugi::xml_node slot : color_scheme.children()) { - if (const std::optional color = read_theme_color_(slot)) { + if (const std::optional color = read_drawing_rgb_color(slot)) { // the slot names are `a:dk1`, `a:lt1`, `a:accent1`, … const std::string_view name = slot.name(); const std::size_t colon = name.find(':'); @@ -88,7 +78,7 @@ presentation::ColorScheme::resolve(const char *name) const { std::optional presentation::read_drawing_color(const pugi::xml_node parent, const ColorScheme *color_scheme) { - if (const std::optional color = read_theme_color_(parent)) { + if (const std::optional color = read_drawing_rgb_color(parent)) { return color; } if (const pugi::xml_attribute scheme_color = diff --git a/src/odr/internal/ooxml/spreadsheet/AGENTS.md b/src/odr/internal/ooxml/spreadsheet/AGENTS.md index 758002dc6..1308472ef 100644 --- a/src/odr/internal/ooxml/spreadsheet/AGENTS.md +++ b/src/odr/internal/ooxml/spreadsheet/AGENTS.md @@ -47,7 +47,9 @@ loads `fonts`, `fills`, `borders`, `cellStyleXfs` and `cellXfs`. A cell's `s` attribute indexes `cellXfs`. The `xf` picks `fontId`, `fillId`, `borderId` and `alignment`, each resolved via its vector. Font, border and alignment are gated by `applyFont`, `applyBorder` and `applyAlignment`. Fill is applied -unconditionally. The legacy indexed colour palette is hardcoded. +unconditionally, and a pattern paints its `fgColor`. A colour is `rgb`, a slot +of the hardcoded legacy palette, or a `theme` slot of the workbook's +`a:clrScheme`, counted `lt1`, `dk1`, `lt2`, `dk2` first, and `tint` moves it. `cellStyleXfs` is loaded but never consulted. **Writing a cell replaces its children, and a written string goes inline.** @@ -87,8 +89,7 @@ formula. 2. `sheet_content` ignores the requested range and returns the full ``. 3. No `cellStyleXfs` inheritance. Borders render as `0.75pt solid` whatever - the style. Font italic, underline and strike are not read. Cell - `protection` is read and dropped. + the style. Cell `protection` is read and dropped. 4. `sheet_set_cell` writes a number, a string or a boolean. `text_set_content` throws `UnsupportedOperation`. A `` is not modelled, so `link_href` is empty. Comments are not modelled. diff --git a/src/odr/internal/ooxml/spreadsheet/README.md b/src/odr/internal/ooxml/spreadsheet/README.md index 92c0230b1..1ab4e229c 100644 --- a/src/odr/internal/ooxml/spreadsheet/README.md +++ b/src/odr/internal/ooxml/spreadsheet/README.md @@ -36,11 +36,11 @@ through the `cellXfs`, `fonts`, `fills` and `borders` indices - [x] size - [x] bold - [x] color - - [ ] italic - - [ ] underline, strike through + - [x] italic + - [x] underline, strike through - [ ] superscript, subscript - [x] cell - - [x] background / fill color + - [x] background / fill color, theme colors and tint - [x] borders (`0.75pt solid` only) - [x] alignment (horizontal, vertical, text rotation) - [ ] protection diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp index 6731b5e95..ee8c01923 100644 --- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp +++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_document.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -86,7 +87,13 @@ Document::Document(std::shared_ptr files) } } - m_style_registry = StyleRegistry(styles_xml.document_element()); + pugi::xml_node theme_root; + if (const std::optional theme_path = + parse_relationship_target(*m_files, workbook_path, "theme"); + theme_path && m_files->is_file(*theme_path)) { + theme_root = parse_xml_(*theme_path).first.document_element(); + } + m_style_registry = StyleRegistry(styles_xml.document_element(), theme_root); const ParseContext parse_context(workbook_path, workbook_relations, m_xml_documents_and_relations, diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp index 6f4cd2113..fc3e74449 100644 --- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp +++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.cpp @@ -1,8 +1,10 @@ #include #include +#include -#include +#include +#include namespace odr::internal::ooxml::spreadsheet { @@ -33,61 +35,53 @@ const std::vector &color_index() { std::optional read_horizontal(const pugi::xml_attribute attribute) { - if (const std::string value = attribute.value(); value == "center") { + const std::string_view value = attribute.value(); + if (value == "left") { + return HorizontalAlign::left; + } + if (value == "center") { return HorizontalAlign::center; } + if (value == "right") { + return HorizontalAlign::right; + } return {}; } std::optional read_vertical(const pugi::xml_attribute attribute) { - if (const std::string value = attribute.value(); value == "center") { + const std::string_view value = attribute.value(); + if (value == "top") { + return VerticalAlign::top; + } + if (value == "center") { return VerticalAlign::middle; } - return {}; -} - -std::optional read_color(const pugi::xml_node node) { - if (const pugi::xml_attribute indexed = node.attribute("indexed")) { - return color_index().at(indexed.as_uint()); - } - if (const pugi::xml_attribute rgb = node.attribute("rgb")) { - const char *value = rgb.value(); - if (std::strlen(value) == 8) { - // the alpha byte is not one: excel ignores it and producers routinely - // write `00`, which would paint nothing at all - const std::uint32_t color = std::strtoull(value, nullptr, 16); - return Color::from_rgb(color); - } - if (std::strlen(value) == 6) { - const std::uint32_t color = std::strtoull(value, nullptr, 16); - return Color::from_rgb(color); - } + if (value == "bottom") { + return VerticalAlign::bottom; } return {}; } -std::optional read_border(const pugi::xml_node node) { - if (!node) { - return {}; - } - std::string result; - if (!node.attribute("style")) { - return {}; - } - // TODO: thin only - result.append("0.75pt solid "); - if (const std::optional color = read_color(node.child("color"))) { - result.append(html::color(*color)); - } - return result; +/// A `CT_BooleanProperty`: present is on, unless `val` says otherwise. +bool read_toggle(const pugi::xml_node node) { + return node && node.attribute("val").as_bool(true); } } // namespace StyleRegistry::StyleRegistry() = default; -StyleRegistry::StyleRegistry(const pugi::xml_node styles_root) { +StyleRegistry::StyleRegistry(const pugi::xml_node styles_root, + const pugi::xml_node theme_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", + "a:accent2", "a:accent3", "a:accent4", "a:accent5", + "a:accent6", "a:hlink", "a:folHlink"}) { + m_theme_colors.push_back(read_drawing_rgb_color(scheme.child(name))); + } + generate_indices_(styles_root); } @@ -134,6 +128,34 @@ ResolvedStyle StyleRegistry::cell_style(const std::uint32_t i) const { return result; } +/// [ECMA-376] 18.8.3 `CT_Color`: `rgb`, a legacy `indexed` slot or a `theme` +/// slot, any of them moved by `tint`. +std::optional +StyleRegistry::read_color_(const pugi::xml_node node) const { + std::optional result; + if (const pugi::xml_attribute theme = node.attribute("theme")) { + if (theme.as_uint() < m_theme_colors.size()) { + result = m_theme_colors[theme.as_uint()]; + } + } else if (const pugi::xml_attribute indexed = node.attribute("indexed")) { + if (indexed.as_uint() < color_index().size()) { + result = color_index()[indexed.as_uint()]; + } + } else if (const pugi::xml_attribute rgb = node.attribute("rgb")) { + const std::string_view value = rgb.value(); + // the alpha byte is not one: excel ignores it and producers routinely + // write `00`, which would paint nothing at all + if (value.size() == 8 || value.size() == 6) { + result = Color::from_rgb(static_cast( + std::strtoull(rgb.value(), nullptr, 16) & 0xffffff)); + } + } + if (const pugi::xml_attribute tint = node.attribute("tint"); result && tint) { + result = apply_tint(*result, tint.as_double()); + } + return result; +} + void StyleRegistry::resolve_font_(const std::uint32_t i, ResolvedStyle &result) const { const pugi::xml_node font = m_fonts_index.at(i); @@ -145,32 +167,59 @@ void StyleRegistry::resolve_font_(const std::uint32_t i, font.child("name").attribute("val")) { result.text_style.font_name = font_name.value(); } - if (font.child("b")) { - result.text_style.font_weight = FontWeight::bold; + if (const pugi::xml_node bold = font.child("b")) { + result.text_style.font_weight = + read_toggle(bold) ? FontWeight::bold : FontWeight::normal; + } + if (const pugi::xml_node italic = font.child("i")) { + result.text_style.font_style = + read_toggle(italic) ? FontStyle::italic : FontStyle::normal; + } + if (const pugi::xml_node underline = font.child("u")) { + result.text_style.font_underline = + std::string_view(underline.attribute("val").value()) != "none"; + } + if (const pugi::xml_node strike = font.child("strike")) { + result.text_style.font_line_through = read_toggle(strike); } if (const pugi::xml_node color = font.child("color")) { - result.text_style.font_color = read_color(color); + result.text_style.font_color = read_color_(color); } } +/// [ECMA-376] 18.8.32: a pattern paints `fgColor`, so a solid fill is that +/// colour; `none`, the default, paints nothing. void StyleRegistry::resolve_fill_(const std::uint32_t i, ResolvedStyle &result) const { - const pugi::xml_node fill = m_fills_index.at(i); - - if (const pugi::xml_node pattern = fill.child("patternFill")) { - result.table_cell_style.background_color = - read_color(pattern.child("bgColor")); + const pugi::xml_node pattern = m_fills_index.at(i).child("patternFill"); + const std::string_view type = pattern.attribute("patternType").value(); + if (!pattern || type.empty() || type == "none") { + return; } + result.table_cell_style.background_color = + read_color_(pattern.child("fgColor")); } void StyleRegistry::resolve_border_(const std::uint32_t i, ResolvedStyle &result) const { const pugi::xml_node border = m_borders_index.at(i); - result.table_cell_style.border.right = read_border(border.child("right")); - result.table_cell_style.border.top = read_border(border.child("top")); - result.table_cell_style.border.left = read_border(border.child("left")); - result.table_cell_style.border.bottom = read_border(border.child("bottom")); + const auto side = + [&](const pugi::xml_node node) -> std::optional { + if (!node.attribute("style")) { + return {}; + } + // TODO: thin only + std::string declaration = "0.75pt solid "; + if (const std::optional color = read_color_(node.child("color"))) { + declaration.append(html::color(*color)); + } + return declaration; + }; + result.table_cell_style.border.right = side(border.child("right")); + result.table_cell_style.border.top = side(border.child("top")); + result.table_cell_style.border.left = side(border.child("left")); + result.table_cell_style.border.bottom = side(border.child("bottom")); } void StyleRegistry::generate_indices_(const pugi::xml_node styles_root) { diff --git a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.hpp b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.hpp index 911660870..528741598 100644 --- a/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.hpp +++ b/src/odr/internal/ooxml/spreadsheet/ooxml_spreadsheet_style.hpp @@ -2,6 +2,7 @@ #include +#include #include #include @@ -11,11 +12,15 @@ namespace odr::internal::ooxml::spreadsheet { class StyleRegistry final { public: StyleRegistry(); - explicit StyleRegistry(pugi::xml_node styles_root); + /// @p theme_root is the `a:theme` of the workbook, null where it has none. + StyleRegistry(pugi::xml_node styles_root, pugi::xml_node theme_root); [[nodiscard]] ResolvedStyle cell_style(std::uint32_t i) const; private: + /// `lt1`, `dk1`, `lt2`, `dk2`, `accent1` to `accent6`, `hlink`, `folHlink`: + /// the order a `theme` index counts in. + std::vector> m_theme_colors; std::vector m_fonts_index; std::vector m_borders_index; std::vector m_fills_index; @@ -24,6 +29,7 @@ class StyleRegistry final { void generate_indices_(pugi::xml_node styles_root); + [[nodiscard]] std::optional read_color_(pugi::xml_node node) const; void resolve_font_(std::uint32_t i, ResolvedStyle &result) const; void resolve_border_(std::uint32_t i, ResolvedStyle &result) const; void resolve_fill_(std::uint32_t i, ResolvedStyle &result) const; diff --git a/src/odr/internal/pdf/AGENTS.md b/src/odr/internal/pdf/AGENTS.md index 66fec44cc..abd4fcc29 100644 --- a/src/odr/internal/pdf/AGENTS.md +++ b/src/odr/internal/pdf/AGENTS.md @@ -190,6 +190,7 @@ above. So each run is raised by one font ascent: | `pdf_writer.*` | `IncrementalWriter`: copies the source through and appends the changed objects under their own xref in the file's flavour. Refuses a recovered or encrypted file | | `pdf_annotation.*` | The markup and ink annotations and their appearance streams. Only the highlight blends Multiply (11.6.4.1) | | `util/math_util.hpp` | `util::math::Transform2D`, PDF row-vector convention | +| `util/color_util.*` | `util::color`: the sRGB transfer, a channel to a byte, HSL and Oklab | Consumers outside the module: `open_strategy.cpp` and `html/pdf_file.cpp` (`create_pdf_service`; the per-font PUA re-encode, OTF wrap and `@font-face`; diff --git a/src/odr/internal/pdf/pdf_color.cpp b/src/odr/internal/pdf/pdf_color.cpp index 2e0f6fd33..bb48b889e 100644 --- a/src/odr/internal/pdf/pdf_color.cpp +++ b/src/odr/internal/pdf/pdf_color.cpp @@ -1,6 +1,7 @@ #include #include +#include #include #include @@ -11,10 +12,8 @@ namespace { double clamp01(const double v) { return std::clamp(v, 0.0, 1.0); } -/// sRGB gamma encode of a linear component (IEC 61966-2-1). double linear_to_srgb(const double c) { - const double v = clamp01(c); - return v <= 0.0031308 ? 12.92 * v : 1.055 * std::pow(v, 1 / 2.4) - 0.055; + return util::color::linear_to_srgb(clamp01(c)); } /// CIE L*a*b* -> sRGB through XYZ (ISO 32000-1 8.6.5.4), under the space's diff --git a/src/odr/internal/pdf/pdf_image.cpp b/src/odr/internal/pdf/pdf_image.cpp index b4f615261..4dc67244b 100644 --- a/src/odr/internal/pdf/pdf_image.cpp +++ b/src/odr/internal/pdf/pdf_image.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include @@ -54,11 +55,6 @@ class BitReader { std::int32_t m_bit{0}; }; -std::uint8_t to_byte(const double v) { - const double scaled = std::lround(std::clamp(v, 0.0, 1.0) * 255.0); - return static_cast(scaled); -} - /// Undo the premultiplication `/SMaskInData 2` declares, leaving the straight /// colour a PNG carries. void unpremultiply(std::string &samples, const std::int32_t components, @@ -72,8 +68,10 @@ void unpremultiply(std::string &samples, const std::int32_t components, return; } const auto value = static_cast(samples[i]); - samples[i] = static_cast( - a == 0 ? 0 : to_byte(std::min(1.0, value / static_cast(a)))); + samples[i] = + static_cast(a == 0 ? 0 + : util::color::to_byte(std::min( + 1.0, value / static_cast(a)))); } } } @@ -188,7 +186,7 @@ std::string pdf::encode_image_png(const std::string &samples, const std::array value{component_value(sample, 0)}; const std::array rgb = color_space.to_rgb(value); for (const double c : rgb) { - palette.push_back(static_cast(to_byte(c))); + palette.push_back(static_cast(util::color::to_byte(c))); } } const std::size_t size = row_bytes * static_cast(height); @@ -217,9 +215,9 @@ std::string pdf::encode_image_png(const std::string &samples, component_values[k] = component_value(sample, k); } const std::array pixel = color_space.to_rgb(component_values); - out[out_index++] = static_cast(to_byte(pixel[0])); - out[out_index++] = static_cast(to_byte(pixel[1])); - out[out_index++] = static_cast(to_byte(pixel[2])); + out[out_index++] = static_cast(util::color::to_byte(pixel[0])); + out[out_index++] = static_cast(util::color::to_byte(pixel[1])); + out[out_index++] = static_cast(util::color::to_byte(pixel[2])); if (has_alpha) { std::uint8_t a = alpha.size() == pixel_count ? alpha[pixel_index] : 0xFF; @@ -281,7 +279,7 @@ std::vector pdf::decode_mask_alpha( native[i++] = value >= 0.5 ? 0x00 : 0xFF; } else { // A soft mask: the grey level is the coverage directly. - native[i++] = to_byte(value); + native[i++] = util::color::to_byte(value); } } } @@ -316,9 +314,9 @@ std::string pdf::encode_stencil_png(const std::string &samples, // of [1 0] swaps that — paint when the decoded value rounds to 0. const bool invert = decode.size() >= 2 && decode[0] > decode[1]; - const std::uint8_t r = to_byte(color[0]); - const std::uint8_t g = to_byte(color[1]); - const std::uint8_t b = to_byte(color[2]); + const std::uint8_t r = util::color::to_byte(color[0]); + const std::uint8_t g = util::color::to_byte(color[1]); + const std::uint8_t b = util::color::to_byte(color[2]); std::string rgba; rgba.resize(static_cast(width) * diff --git a/src/odr/internal/util/color_util.cpp b/src/odr/internal/util/color_util.cpp new file mode 100644 index 000000000..a9d394fe7 --- /dev/null +++ b/src/odr/internal/util/color_util.cpp @@ -0,0 +1,90 @@ +#include + +#include +#include + +namespace odr::internal { + +std::uint8_t util::color::to_byte(const double c) noexcept { + return static_cast(std::lround(std::clamp(c, 0.0, 1.0) * 255)); +} + +double util::color::srgb_to_linear(const double c) noexcept { + return c <= 0.04045 ? c / 12.92 : std::pow((c + 0.055) / 1.055, 2.4); +} + +double util::color::linear_to_srgb(const double c) noexcept { + return c <= 0.0031308 ? c * 12.92 : 1.055 * std::pow(c, 1.0 / 2.4) - 0.055; +} + +util::color::Hsl util::color::Hsl::from_color(const Color &color) noexcept { + const double r = color.red / 255.0; + const double g = color.green / 255.0; + const double b = color.blue / 255.0; + const double max = std::max({r, g, b}); + const double min = std::min({r, g, b}); + Hsl result; + result.lightness = (max + min) / 2; + if (max == min) { + return result; + } + const double d = max - min; + result.saturation = + result.lightness > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max == r) { + result.hue = (g - b) / d + (g < b ? 6 : 0); + } else if (max == g) { + result.hue = (b - r) / d + 2; + } else { + result.hue = (r - g) / d + 4; + } + result.hue /= 6; + return result; +} + +Color util::color::Hsl::to_color(const std::uint8_t alpha) const noexcept { + const double q = lightness < 0.5 + ? lightness * (1 + saturation) + : lightness + saturation - lightness * saturation; + const double p = 2 * lightness - q; + const auto channel = [&](double t) { + t -= std::floor(t); + if (t < 1.0 / 6) { + return to_byte(p + (q - p) * 6 * t); + } + if (t < 1.0 / 2) { + return to_byte(q); + } + if (t < 2.0 / 3) { + return to_byte(p + (q - p) * (2.0 / 3 - t) * 6); + } + return to_byte(p); + }; + return {channel(hue + 1.0 / 3), channel(hue), channel(hue - 1.0 / 3), alpha}; +} + +util::color::Oklab util::color::Oklab::from_color(const Color &color) noexcept { + const double r = srgb_to_linear(color.red / 255.0); + const double g = srgb_to_linear(color.green / 255.0); + const double b = srgb_to_linear(color.blue / 255.0); + const double l = + std::cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const double m = + std::cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const double s = + std::cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return {0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s}; +} + +std::array util::color::Oklab::to_linear_rgb() const noexcept { + const double l_ = std::pow(l + 0.3963377774 * a + 0.2158037573 * b, 3); + const double m_ = std::pow(l - 0.1055613458 * a - 0.0638541728 * b, 3); + const double s_ = std::pow(l - 0.0894841775 * a - 1.2914855480 * b, 3); + return {4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_, + -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_, + -0.0041960863 * l_ - 0.7034186147 * m_ + 1.7076147010 * s_}; +} + +} // namespace odr::internal diff --git a/src/odr/internal/util/color_util.hpp b/src/odr/internal/util/color_util.hpp new file mode 100644 index 000000000..28d81a0f6 --- /dev/null +++ b/src/odr/internal/util/color_util.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include + +#include +#include + +namespace odr::internal::util::color { + +/// @p c in [0, 1], clamped, as an 8-bit channel. +[[nodiscard]] std::uint8_t to_byte(double c) noexcept; + +/// The sRGB transfer function (IEC 61966-2-1) and its inverse. Both keep 0 and +/// 1 in place and rise, so a clamp before or after gives the same result. +[[nodiscard]] double srgb_to_linear(double c) noexcept; +[[nodiscard]] double linear_to_srgb(double c) noexcept; + +/// Hue, saturation and lightness, each in [0, 1]. +struct Hsl final { + double hue{0}; + double saturation{0}; + double lightness{0}; + + [[nodiscard]] static Hsl from_color(const Color &color) noexcept; + + [[nodiscard]] Color to_color(std::uint8_t alpha) const noexcept; +}; + +/// A perceptual space: equal steps of `l` look equal, and `a` and `b` hold the +/// hue and the chroma. +struct Oklab final { + double l{0}; + double a{0}; + double b{0}; + + [[nodiscard]] static Oklab from_color(const Color &color) noexcept; + + /// Linear sRGB, possibly outside [0, 1]. + [[nodiscard]] std::array to_linear_rgb() const noexcept; +}; + +} // namespace odr::internal::util::color diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 377dd1f7f..c01fcc1cb 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -99,6 +99,7 @@ add_executable(odr_test "src/internal/ooxml/ooxml_crypto_test.cpp" "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_value_test.cpp" "src/internal/ooxml/ooxml_spreadsheet_write_test.cpp" "src/internal/ooxml/ooxml_util_test.cpp" @@ -121,6 +122,7 @@ add_executable(odr_test "src/internal/pdf/pdf_image.cpp" "src/internal/pdf/pdf_jbig2.cpp" "src/internal/pdf/pdf_jpx.cpp" + "src/internal/util/color_util_test.cpp" "src/internal/util/math_util_test.cpp" "src/internal/pdf/pdf_object.cpp" "src/internal/pdf/pdf_object_parser.cpp" diff --git a/test/data.cmake b/test/data.cmake index 4b9e6bf43..91c390746 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,9 +17,9 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "4db649203f036669dc4606972ae6f3959032fc84") + REVISION "f0cea38042eabbb5a67168c9ac3527906eb00559") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "51aa4659104e77cf6edf0ee6179718c8cf342469") + REVISION "0ebf95002a40653b0d8966c9a4b0acd2d56cf11d") diff --git a/test/src/internal/ooxml/ooxml_spreadsheet_style_test.cpp b/test/src/internal/ooxml/ooxml_spreadsheet_style_test.cpp new file mode 100644 index 000000000..6060e1cb6 --- /dev/null +++ b/test/src/internal/ooxml/ooxml_spreadsheet_style_test.cpp @@ -0,0 +1,122 @@ +#include + +#include + +#include + +#include + +#include + +#include + +using namespace odr; +using namespace odr::internal; +using namespace odr::internal::ooxml::spreadsheet; + +namespace { + +constexpr const char *theme = + R"()" + R"()" + R"()" + R"()" + R"()" + R"()" + R"()"; + +/// The first `xf` of `cellXfs`, over @p fonts, @p fills and the `xf` +/// attributes and children in @p xf. +ResolvedStyle resolve(const std::string &fonts, const std::string &fills, + const std::string &xf) { + pugi::xml_document styles; + pugi::xml_document theme_document; + const std::string xml = "" + fonts + "" + + fills + "" + xf + + ""; + EXPECT_TRUE(styles.load_string(xml.c_str())); + EXPECT_TRUE(theme_document.load_string(theme)); + return StyleRegistry(styles.document_element(), + theme_document.document_element()) + .cell_style(0); +} + +std::string fill_of(const std::string &fill) { + const ResolvedStyle style = + resolve("", "" + fill + "", R"()"); + const std::optional color = style.table_cell_style.background_color; + return color ? internal::html::color(*color) : "none"; +} + +TextStyle font_of(const std::string &font) { + return resolve("" + font + "", "", + R"()") + .text_style; +} + +} // namespace + +TEST(OoxmlSpreadsheetStyle, a_solid_fill_paints_its_foreground) { + EXPECT_EQ(fill_of(R"()" + R"()" + R"()"), + "#dee6ef"); +} + +TEST(OoxmlSpreadsheetStyle, a_fill_of_no_pattern_paints_nothing) { + EXPECT_EQ(fill_of(R"()"), "none"); + EXPECT_EQ(fill_of(R"()"), + "none"); +} + +TEST(OoxmlSpreadsheetStyle, a_theme_colour_counts_light_before_dark) { + EXPECT_EQ(fill_of(R"()" + R"()"), + "#ffffff"); + EXPECT_EQ(fill_of(R"()" + R"()"), + "#4472c4"); +} + +// Excel's "Blue, Accent 1, Darker 25%" and "Lighter 80%". +TEST(OoxmlSpreadsheetStyle, a_tint_moves_the_lightness) { + EXPECT_EQ(fill_of(R"()" + R"()" + R"()"), + "#2f5597"); + EXPECT_EQ(fill_of(R"()" + R"()" + R"()"), + "#dae3f3"); +} + +TEST(OoxmlSpreadsheetStyle, a_font_states_its_toggles) { + const TextStyle on = font_of(R"()"); + EXPECT_EQ(on.font_weight, FontWeight::bold); + EXPECT_EQ(on.font_style, FontStyle::italic); + EXPECT_EQ(on.font_underline, true); + EXPECT_EQ(on.font_line_through, true); + + const TextStyle off = + font_of(R"()"); + EXPECT_EQ(off.font_weight, FontWeight::normal); + EXPECT_EQ(off.font_style, FontStyle::normal); + EXPECT_EQ(off.font_underline, false); + EXPECT_EQ(off.font_line_through, false); +} + +TEST(OoxmlSpreadsheetStyle, alignment_reads_every_side) { + const auto align = [](const std::string &side, const std::string &value) { + return resolve("", "", + R"()") + .table_cell_style; + }; + EXPECT_EQ(align("horizontal", "left").horizontal_align, + HorizontalAlign::left); + EXPECT_EQ(align("horizontal", "right").horizontal_align, + HorizontalAlign::right); + EXPECT_EQ(align("horizontal", "general").horizontal_align, std::nullopt); + EXPECT_EQ(align("vertical", "top").vertical_align, VerticalAlign::top); + EXPECT_EQ(align("vertical", "bottom").vertical_align, VerticalAlign::bottom); +} diff --git a/test/src/internal/util/color_util_test.cpp b/test/src/internal/util/color_util_test.cpp new file mode 100644 index 000000000..4a62ab3fd --- /dev/null +++ b/test/src/internal/util/color_util_test.cpp @@ -0,0 +1,63 @@ +#include + +#include + +#include + +#include + +using namespace odr; +using namespace odr::internal::util::color; + +namespace { + +constexpr std::array samples = {0x000000, 0xffffff, 0x4472c4, + 0xdee6ef, 0x7f7f7f, 0xc00000}; + +} // namespace + +TEST(ColorUtil, to_byte_clamps_and_rounds) { + EXPECT_EQ(to_byte(-0.5), 0); + EXPECT_EQ(to_byte(0.5), 128); + EXPECT_EQ(to_byte(1.5), 255); +} + +TEST(ColorUtil, srgb_transfer_round_trips) { + EXPECT_NEAR(srgb_to_linear(0.5), 0.214041, 1e-6); + for (const double c : {0.0, 0.01, 0.5, 1.0}) { + EXPECT_NEAR(linear_to_srgb(srgb_to_linear(c)), c, 1e-12); + } +} + +TEST(Hsl, from_color) { + const Hsl hsl = Hsl::from_color(0x4472c4_rgb); + EXPECT_NEAR(hsl.hue * 360, 218.4, 0.1); + EXPECT_NEAR(hsl.saturation, 0.521, 1e-3); + EXPECT_NEAR(hsl.lightness, 0.518, 1e-3); + + EXPECT_EQ(Hsl::from_color(0x7f7f7f_rgb).saturation, 0); +} + +TEST(Hsl, round_trips) { + for (const std::uint32_t rgb : samples) { + EXPECT_EQ(Hsl::from_color(Color::from_rgb(rgb)).to_color(255).rgb(), rgb); + } +} + +TEST(Oklab, white_has_no_chroma) { + const Oklab white = Oklab::from_color(0xffffff_rgb); + EXPECT_NEAR(white.l, 1, 1e-6); + EXPECT_NEAR(white.a, 0, 1e-6); + EXPECT_NEAR(white.b, 0, 1e-6); +} + +TEST(Oklab, round_trips) { + for (const std::uint32_t rgb : samples) { + const std::array linear = + Oklab::from_color(Color::from_rgb(rgb)).to_linear_rgb(); + const Color color(to_byte(linear_to_srgb(linear[0])), + to_byte(linear_to_srgb(linear[1])), + to_byte(linear_to_srgb(linear[2]))); + EXPECT_EQ(color.rgb(), rgb); + } +}