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 a7b838283..6cbe111db 100644 --- a/src/odr/internal/ooxml/ooxml_util.cpp +++ b/src/odr/internal/ooxml/ooxml_util.cpp @@ -3,12 +3,12 @@ #include #include #include +#include #include #include #include #include -#include #include #include #include @@ -220,48 +220,10 @@ ooxml::read_drawing_rgb_color(const pugi::xml_node parent) { } Color ooxml::apply_tint(const Color &color, const double tint) { - 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}); - double hue = 0; - double saturation = 0; - double lightness = (max + min) / 2; - if (max != min) { - const double d = max - min; - saturation = lightness > 0.5 ? d / (2 - max - min) : d / (max + min); - if (max == r) { - hue = (g - b) / d + (g < b ? 6 : 0); - } else if (max == g) { - hue = (b - r) / d + 2; - } else { - hue = (r - g) / d + 4; - } - hue /= 6; - } - - lightness = tint < 0 ? lightness * (1 + tint) : lightness * (1 - tint) + tint; - - 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); - double c = p; - if (t < 1.0 / 6) { - c = p + (q - p) * 6 * t; - } else if (t < 1.0 / 2) { - c = q; - } else if (t < 2.0 / 3) { - c = p + (q - p) * (2.0 / 3 - t) * 6; - } - return static_cast( - std::lround(std::clamp(c, 0.0, 1.0) * 255)); - }; - return {channel(hue + 1.0 / 3), channel(hue), channel(hue - 1.0 / 3), - color.alpha}; + 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 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 7967f02d9..c01fcc1cb 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -122,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/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); + } +}