Skip to content

Commit 8319e4b

Browse files
committed
feat(text): preserve HTML foreground colors
1 parent d4f0f0e commit 8319e4b

3 files changed

Lines changed: 95 additions & 17 deletions

File tree

include/aniparse/types/Text.hpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* Author: Toilettrauma <macosinternal@gmail.com>
55
*/
66
#pragma once
7+
#include <cstdint>
78
#include <string>
89
#include <variant>
910
#include <vector>
@@ -26,10 +27,18 @@ struct TextStyle {
2627
Kind kind;
2728
};
2829

30+
/// Foreground colour of an inline run. Components are straight sRGB in the range 0...255.
31+
struct TextColor {
32+
std::uint8_t red;
33+
std::uint8_t green;
34+
std::uint8_t blue;
35+
std::uint8_t alpha = 255;
36+
};
37+
2938
struct TextAttributeInfo {
3039
int start;
3140
int end;
32-
std::variant<Hyperlink, TextStyle> data;
41+
std::variant<Hyperlink, TextStyle, TextColor> data;
3342
};
3443

3544
/**

src/utility/HtmlText.cpp

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
#include <optional>
1313
#include <string>
14+
#include <string_view>
1415

1516
namespace aniparse::text {
1617
namespace {
@@ -27,6 +28,57 @@ std::optional<TextStyle::Kind> style_of(std::string_view tag) {
2728
return std::nullopt;
2829
}
2930

31+
std::string_view trim(std::string_view value) {
32+
while (!value.empty() && (value.front() == ' ' || value.front() == '\t' || value.front() == '\n' || value.front() == '\r'))
33+
value.remove_prefix(1);
34+
while (!value.empty() && (value.back() == ' ' || value.back() == '\t' || value.back() == '\n' || value.back() == '\r'))
35+
value.remove_suffix(1);
36+
return value;
37+
}
38+
39+
int hex_digit(char character) {
40+
if (character >= '0' && character <= '9') return character - '0';
41+
if (character >= 'a' && character <= 'f') return character - 'a' + 10;
42+
if (character >= 'A' && character <= 'F') return character - 'A' + 10;
43+
return -1;
44+
}
45+
46+
std::optional<TextColor> color_value(std::string_view value) {
47+
value = trim(value);
48+
if (value.size() < 4 || value.front() != '#') return std::nullopt;
49+
value.remove_prefix(1);
50+
if (value.size() != 3 && value.size() != 4 && value.size() != 6 && value.size() != 8) return std::nullopt;
51+
auto component = [&](std::size_t offset) -> std::optional<std::uint8_t> {
52+
const int high = hex_digit(value[offset]);
53+
const int low = value.size() <= 4 ? high : hex_digit(value[offset + 1]);
54+
if (high < 0 || low < 0) return std::nullopt;
55+
return static_cast<std::uint8_t>(high * 16 + low);
56+
};
57+
const std::size_t step = value.size() <= 4 ? 1 : 2;
58+
const auto red = component(0), green = component(step), blue = component(step * 2);
59+
if (!red || !green || !blue) return std::nullopt;
60+
const auto alpha = value.size() == 4 || value.size() == 8 ? component(step * 3) : std::optional<std::uint8_t>{ 255 };
61+
if (!alpha) return std::nullopt;
62+
return TextColor{ *red, *green, *blue, *alpha };
63+
}
64+
65+
std::optional<TextColor> color_of(const DOMElementView& element) {
66+
if (const auto value = element.get_attr("color"))
67+
if (const auto color = color_value(*value)) return color;
68+
if (const auto style = element.get_attr("style")) {
69+
std::string_view declarations = *style;
70+
while (!declarations.empty()) {
71+
const std::size_t separator = declarations.find(';');
72+
const std::string_view declaration = trim(declarations.substr(0, separator));
73+
declarations = separator == std::string_view::npos ? std::string_view{} : declarations.substr(separator + 1);
74+
const std::size_t colon = declaration.find(':');
75+
if (colon == std::string_view::npos || trim(declaration.substr(0, colon)) != "color") continue;
76+
if (const auto color = color_value(declaration.substr(colon + 1))) return color;
77+
}
78+
}
79+
return std::nullopt;
80+
}
81+
3082
// Emit a line break only when the text does not already end in one, so successive block
3183
// tags and <br>s collapse instead of stacking blank lines.
3284
void break_line(std::string& text) {
@@ -55,26 +107,26 @@ void walk(DOMNodeView node, AttributedText& out) {
55107
const std::size_t n = out.text.size();
56108
if (n < 2 || out.text[n - 1] != '\n' || out.text[n - 2] != '\n')
57109
out.text += '\n';
58-
} else if (tag == "p" || tag == "div" || tag == "li") {
110+
continue;
111+
}
112+
if (tag == "p" || tag == "div" || tag == "li") {
59113
break_line(out.text);
60114
walk(child, out);
61115
break_line(out.text);
62-
} else if (tag == "a") {
63-
const int start = static_cast<int>(out.text.size());
64-
walk(child, out);
65-
const int end = static_cast<int>(out.text.size());
66-
if (const auto href = element.get_attr("href"); href && !href->empty() && end > start)
67-
out.attributes.push_back({ start, end, Hyperlink{ std::string(*href) } });
68-
} else if (const auto kind = style_of(tag)) {
69-
const int start = static_cast<int>(out.text.size());
70-
walk(child, out);
71-
const int end = static_cast<int>(out.text.size());
72-
if (end > start)
73-
out.attributes.push_back({ start, end, TextStyle{ *kind } });
74-
} else {
75-
// Unknown/transparent tag (span, wbr, ...): keep its text, drop the tag.
76-
walk(child, out);
116+
continue;
77117
}
118+
119+
const int start = static_cast<int>(out.text.size());
120+
walk(child, out);
121+
const int end = static_cast<int>(out.text.size());
122+
if (end <= start) continue;
123+
if (tag == "a")
124+
if (const auto href = element.get_attr("href"); href && !href->empty())
125+
out.attributes.push_back({ start, end, Hyperlink{ std::string(*href) } });
126+
if (const auto kind = style_of(tag))
127+
out.attributes.push_back({ start, end, TextStyle{ *kind } });
128+
if (const auto color = color_of(element))
129+
out.attributes.push_back({ start, end, *color });
78130
}
79131
}
80132
} // namespace

tests/HtmlTextTest.cpp

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
using aniparse::AttributedText;
1414
using aniparse::Hyperlink;
15+
using aniparse::TextColor;
1516
using aniparse::TextStyle;
1617
using aniparse::text::from_html;
1718

@@ -86,6 +87,22 @@ TEST_CASE("from_html keeps text of unknown tags", "[htmltext]") {
8687
REQUIRE(r.attributes.empty());
8788
}
8889

90+
TEST_CASE("from_html carries foreground colours", "[htmltext]") {
91+
const AttributedText r = from_html(R"(<span style="font-weight: 400; color: #789">dim</span> <font color="#10203080">faint</font>)");
92+
REQUIRE(r.text == "dim faint");
93+
bool dim = false, faint = false;
94+
for (const auto& attribute : r.attributes) {
95+
const auto* color = std::get_if<TextColor>(&attribute.data);
96+
if (!color) continue;
97+
if (covered(r, attribute) == "dim")
98+
dim = color->red == 0x77 && color->green == 0x88 && color->blue == 0x99 && color->alpha == 0xff;
99+
if (covered(r, attribute) == "faint")
100+
faint = color->red == 0x10 && color->green == 0x20 && color->blue == 0x30 && color->alpha == 0x80;
101+
}
102+
REQUIRE(dim);
103+
REQUIRE(faint);
104+
}
105+
89106
TEST_CASE("from_html on a real AniList-shaped description", "[htmltext]") {
90107
// The exact shape AniList returns with asHtml:true: block <p>, <br> runs, a source
91108
// attribution, an italic label, and a <ul>/<li> list.

0 commit comments

Comments
 (0)