Files
mu4e/lib/utils/mu-html-to-text.cc
Dirk-Jan C. Binnema 4489d513be migrate to std::ranges
Move the various seq_ functions, as well as std::(stable_)sort,
std::accumulate, std::transform, std::find, std::find_if to their C++20
std::ranges counterparts.
2026-08-20 15:05:31 -07:00

746 lines
16 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
** Copyright (C) 2026 Dirk-Jan C. Binnema <djcb@djcbsoftware.nl>
**
** This program is free software; you can redistribute it and/or modify it
** under the terms of the GNU General Public License as published by the
** Free Software Foundation; either version 3, or (at your option) any
** later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program; if not, write to the Free Software Foundation,
** Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
**
*/
#include "mu-utils.hh"
#include "mu-option.hh"
#include <string>
#include <array>
#include <string_view>
#include <algorithm>
#include <charconv>
using namespace Mu;
static bool
starts_with(std::string_view haystack, std::string_view needle)
{
if (needle.size() > haystack.size())
return false;
for (size_t c{}; c != needle.size(); ++c)
if (to_ascii_lower(haystack[c]) != to_ascii_lower(needle[c]))
return false;
return true;
}
static bool
matches(std::string_view haystack, std::string_view needle)
{
if (needle.size() != haystack.size())
return false;
else
return starts_with(haystack, needle);
}
/**
* HTML parsing context
*
*/
class Context {
public:
/**
* Construct a parsing context
*
* @param html some html to parse
*/
Context(const std::string& html): html_{html}, pos_{} {
raw_scraped_.reserve(html.size()/2);
}
/**
* Are we done with the html blob, i.e, has it been fully scraped?
*
* @return true or false
*/
bool done() const {
return pos_ >= html_.size();
}
/**
* Get the current position
*
* @return position
*/
size_t position() const {
return pos_;
}
/**
* Get the size of the HTML
*
* @return size
*/
size_t size() const {
return html_.size();
}
/**
* Advance the position by _n_ characters.
*
* @param n number by which to advance.
*/
void advance(size_t n=1) {
if (pos_ + n > html_.size())
throw std::range_error("out of range");
pos_ += n;
}
/**
* Are we looking at the given string?
*
* @param str string to match (case-insensitive)
*
* @return true or false
*/
bool looking_at(std::string_view str) const {
if (pos_ >= html_.size() || pos_ + str.size() > html_.size())
return false;
else
return matches({html_.data()+pos_, str.size()}, str);
}
/**
* Grab a substring-view from the html
*
* @param fpos starting position
* @param len length
*
* @return string view
*/
std::string_view substr(size_t fpos, size_t len) const {
if (fpos + len > html_.size())
throw std::range_error(mu_format("{} + {} > {}",
fpos, len, html_.size()));
else
return { html_.data() + fpos, len };
}
/**
* Grab the string of alphabetic characters at the
* head (pos) of the context, and advance over it.
*
* @return the head-word or empty
*/
std::string_view eat_head_word() {
size_t start_pos{pos_};
while (!done()) {
if (!is_ascii_alpha(html_.at(pos_)))
break;
++pos_;
}
return {html_.data() + start_pos, pos_ - start_pos};
}
/**
* Get the scraped data; only available when done()
* @return scraped data
*/
std::string scraped() {
return cleanup(raw_scraped_);
}
/**
* Get the raw scrape buffer, where we can append
* scraped data.
*
* @return the buffer
*/
std::string& raw_scraped() {
return raw_scraped_;
}
/**
* Get a reference to the HTML
*
* @return html
*/
const std::string& html() const { return html_; }
private:
/**
* Cleanup some raw scraped html: remove superfluous
* whitespace, avoid too long lines.
*
* @param unclean
*
* @return cleaned up string.
*/
std::string cleanup(const std::string& unclean) const {
// reduce whitespace and avoid too long lines;
// makes it easier to debug.
bool was_wspace{};
size_t col{};
std::string clean;
clean.reserve(unclean.size()/2);
for(auto&& c: unclean) {
if (is_ascii_space(c)) {
was_wspace = true;
continue;
}
++col;
if (was_wspace) {
if (col > 80) {
clean += '\n';
col = 0;
} else if (!clean.empty())
clean += ' ';
was_wspace = false;
}
clean += c;
}
return clean;
}
const std::string& html_; // no copy!
size_t pos_{};
std::string raw_scraped_;
};
[[maybe_unused]] static auto
format_as(const Context& ctx)
{
return mu_format("<{}:{}: '{}'>",
ctx.position(), ctx.size(),
ctx.substr(ctx.position(),
std::min(static_cast<size_t>(8),
ctx.size() - ctx.position())));
}
// skip until (and over) the closing quote; pos must be just after the
// opening quote.
static void
skip_quoted(Context& ctx, std::string_view quote)
{
while(!ctx.done()) {
if (ctx.looking_at(quote)) { // closing quote
ctx.advance();
return;
}
ctx.advance();
}
}
// attempt to skip over <script> / <style> blocks
static void
skip_script_style(Context& ctx, std::string_view tag)
{
// <script> or <style> must be ignored
bool quoted{}, squoted{};
bool inl_comment{};
bool endl_comment{};
// '//'-comments exist in javascript but not in css, where '//' may
// occur in urls.
const auto is_script{matches(tag, "script")};
const auto end_tag_str = mu_format("</{}>", tag);
const std::string_view end_tag{end_tag_str};
while (!ctx.done()) {
if (inl_comment) {
if (ctx.looking_at("*/")) {
inl_comment = false;
ctx.advance(2);
} else
ctx.advance();
continue;
}
if (endl_comment) {
if (ctx.looking_at("\n"))
endl_comment = false;
ctx.advance();
continue;
}
if (!quoted && !squoted) {
if (ctx.looking_at(end_tag)) {
ctx.advance(end_tag.size());
break; /* we're done, finally! */
}
if (ctx.looking_at("/*")) {
inl_comment = true;
ctx.advance(2);
continue;
}
if (is_script && ctx.looking_at("//")) {
endl_comment = true;
ctx.advance(2);
continue;
}
}
if ((quoted || squoted) && ctx.looking_at("\\")) {
// skip the escaped character as well
ctx.advance();
if (!ctx.done())
ctx.advance();
continue;
}
if (ctx.looking_at("\"") && !squoted) {
quoted = !quoted;
ctx.advance();
continue;
}
if (ctx.looking_at("'") && !quoted) {
squoted = !squoted;
ctx.advance();
continue;
}
if (ctx.looking_at("\n")) // strings don't span lines; this guards
quoted = squoted = false; // against unterminated quotes
ctx.advance();
}
}
// comment block; ignore completely
// pos will be immediately after the '<!--
static void
comment(Context& ctx)
{
constexpr std::string_view comment_endtag{"-->"};
while (!ctx.done()) {
if (ctx.looking_at(comment_endtag)) {
ctx.advance(comment_endtag.size());
ctx.raw_scraped() += ' ';
return;
}
ctx.advance();
}
}
static bool // do we need a SPC separator for this tag?
needs_separator(std::string_view tagname)
{
constexpr auto nosep_tags = std::to_array<const char*>({
"b", "em", "i", "s", "strike", "tt", "u"
});
return !std::ranges::any_of(nosep_tags, [&](auto&& t){return matches(tagname, t);});
}
static bool // do we need to skip the element completely?
is_skip_element(std::string_view tagname)
{
constexpr auto skip_tags = std::to_array<const char*>({
"head", "title"
});
return std::ranges::any_of(skip_tags, [&](auto&& t){return matches(tagname, t);});
}
// skip the end-tag
static void
end_tag(Context& ctx)
{
while (!ctx.done()) {
if (ctx.looking_at(">")) {
ctx.advance();
return;
}
ctx.advance();
}
}
// skip the whole element, until (and over) its matching end-tag. As a guard
// against a missing end-tag, stop before an opening <body>, so we cannot
// swallow the message text itself.
static void
skip_element(Context& ctx, std::string_view tagname)
{
const auto end_tag_str = mu_format("</{}>", tagname);
const std::string_view end_tag{end_tag_str};
while (!ctx.done()) {
if (ctx.looking_at(end_tag)) {
ctx.advance(end_tag.size());
return;
}
if (ctx.looking_at("<body"))
return; // leave it to tag()
ctx.advance();
}
}
// the start of a tag, i.e., pos will be just after the '<'
static void
tag(Context& ctx)
{
// some elements we want to skip completely,
// for others just the tags.
constexpr std::string_view comment_start {"!--"};
if (ctx.looking_at(comment_start)) {
ctx.advance(comment_start.size());
comment(ctx);
return;
}
if (ctx.looking_at("/")) {
ctx.advance();
end_tag(ctx);
return;
}
auto tagname = ctx.eat_head_word();
if (matches(tagname, "script") || matches(tagname, "style")) {
skip_script_style(ctx, tagname);
ctx.raw_scraped() += ' ';
return;
} else if (is_skip_element(tagname)) {
skip_element(ctx, tagname);
ctx.raw_scraped() += ' ';
return;
}
const auto needs_sepa = needs_separator(tagname);
while (!ctx.done()) {
if (ctx.looking_at("\"")) {
ctx.advance();
skip_quoted(ctx, "\"");
continue;
}
if (ctx.looking_at("'")) {
ctx.advance();
skip_quoted(ctx, "'");
continue;
}
if (ctx.looking_at(">")) {
ctx.advance();
if (needs_sepa)
ctx.raw_scraped() += ' ';
return;
}
ctx.advance();
}
}
// handle an html-entity; pos must be just after the '&'. If it does not
// look like an entity, keep the '&' as literal text.
static void
html_escape_char(Context& ctx)
{
// accented characters are added unaccented, lowercase, since that's
// what we do for indexing anyway; this handles the common
// "<base-char><accent-name>" entities, e.g. &aacute; and &Ocirc;
constexpr auto accents = std::to_array<const char*>({
"acute",
"breve",
"caron",
"circ",
"grave",
"horn",
"macr",
"slash",
"strok",
"tilde",
"uml",
});
struct Named { std::string_view name; std::string_view repl; };
constexpr auto named = std::to_array<Named>({
{"amp", "&"},
{"apos", "'"},
{"gt", ">"},
{"ldquo", "\""},
{"lsquo", "'"},
{"lt", "<"},
{"mdash", "-"},
{"nbsp", " "},
{"ndash", "-"},
{"quot", "\""},
{"rdquo", "\""},
{"rsquo", "'"},
});
const auto unescape=[&](std::string_view esc)->std::string {
if (esc.front() == '#') { // numeric entity, e.g. &#233; or &#xe9;
auto num{esc.substr(1)};
int base{10};
if (!num.empty() && to_ascii_lower(num.front()) == 'x') {
num = num.substr(1);
base = 16;
}
gunichar uc{};
const auto [ptr, ec] = std::from_chars(
num.data(), num.data() + num.size(), uc, base);
if (ec == std::errc{} && ptr == num.data() + num.size() &&
uc >= 0x20 && g_unichar_validate(uc)) {
char buf[6];
return {buf, static_cast<size_t>(
g_unichar_to_utf8(uc, buf))};
}
return " ";
}
for (auto&& n: named)
if (matches(esc, n.name))
return std::string{n.repl};
if (std::ranges::any_of(accents, [&](auto&& a){
return starts_with(esc.substr(1), a);}))
return std::string(1, to_ascii_lower(esc.front()));
return " ";
};
// find the terminating ';'; entities are short and consist of
// alphanumerics or '#'; anything else is a bare '&'.
constexpr size_t max_entity_len{10};
const auto start_pos{ctx.position()};
Option<std::string_view> entity;
for (size_t len{}; len <= max_entity_len &&
start_pos + len < ctx.size(); ++len) {
const auto c{ctx.html()[start_pos + len]};
if (c == ';') {
if (len != 0)
entity = ctx.substr(start_pos, len);
break;
}
if (!is_ascii_alnum(c) && c != '#')
break;
}
if (!entity) {
ctx.raw_scraped() += '&';
return;
}
ctx.raw_scraped() += unescape(*entity);
ctx.advance(entity->size() + 1); // the entity and its ';'
}
// a block of text to be scraped
static void
text(Context& ctx)
{
size_t start_pos{ctx.position()};
while (!ctx.done()) {
if (ctx.looking_at("&")) {
ctx.raw_scraped() += ctx.substr(start_pos,
ctx.position() - start_pos);
ctx.advance();
html_escape_char(ctx);
start_pos = ctx.position();
} else if (ctx.looking_at("<")) {
ctx.raw_scraped() += ctx.substr(start_pos,
ctx.position() - start_pos);
ctx.advance();
tag(ctx);
start_pos = ctx.position();
} else
ctx.advance();
}
ctx.raw_scraped() += ctx.substr(start_pos, ctx.size() - start_pos);
}
std::string
Mu::html_to_text(const std::string& html)
{
Context ctx{html};
text(ctx);
return ctx.scraped();
}
#ifdef BUILD_TESTS
#include "mu-test-utils.hh"
static void
test_basics()
{
static std::vector<std::pair<std::string, std::string>>
tests = {
{ "<!-- Hello -->A", "A" },
{ "A<!-- Test -->B", "A B" },
{ "A<i>a</i><b>p</b>", "Aap"},
{ "N&oacute;&Ocirc;t", "Noot"},
{
"foo<!-- bar --><i>c</i>uu<bla>x</bla>"
"<!--hello -->world<!--",
"foo cuu x world"
}
};
for (auto&& test: tests)
assert_equal(html_to_text(test.first), test.second);
}
static void
test_quoted()
{
static std::vector<std::pair<std::string, std::string>>
tests = {
{ R"(<i>hello, <b bar="/b">world!</b>)",
"hello, world!"},
// '>' inside a quoted attribute value
{ R"(<a href="http://example.com" title="x > y">link</a> text)",
"link text"},
};
for (auto&& test: tests)
assert_equal(html_to_text(test.first), test.second);
}
static void
test_script_style()
{
static std::vector<std::pair<std::string, std::string>>
tests = {
{R"(<i>hello, </i><script language="javascript">
function foo() {
alert("Stroopwafel!"); // test
}
</script>world!)",
"hello, world!"},
// tags are case-insensitive
{ "A<SCRIPT>var x = \"hello world\";</SCRIPT>B",
"A B"},
// '//'-comment ending in the same line as the end-tag
{ "A<script>// it's a comment\nvar x = 1;</script>B",
"A B"},
// end-tag inside a string
{ "A<script>var s = \"</script>\"; var t = 1;</script>B",
"A B"},
{ "A<style>p::before { content: '</style>'; }</style>B",
"A B"},
// '//' in css is not a comment
{ "A<style>p { background: url(//cdn.example.com/i.png) }</style>B",
"A B"},
};
for (auto&& test: tests)
assert_equal(html_to_text(test.first), test.second);
}
static void
test_entities() // entities
{
static std::vector<std::pair<std::string, std::string>>
tests = {
// bare '&' is not an entity; keep the text
{ "Tom & Jerry; forever", "Tom & Jerry; forever"},
{ "AT&amp;T &lt;3", "AT&T <3"},
// accents are dropped
{ "p&aacute;gina", "pagina"},
// numeric entities, decimal and hex
{ "don&#39;t don&#x2019;t", "don't dont"},
{ "caf&#233;", "café"},
// invalid numeric entities become a space
{ "A&#xfffffff;B&#;C", "A B C"},
};
for (auto&& test: tests)
assert_equal(html_to_text(test.first), test.second);
}
static void
test_skipped() // skipped elements
{
static std::vector<std::pair<std::string, std::string>>
tests = {
{ "<html><head><title>Title</title>"
"<meta charset=\"utf-8\"></head>"
"<body>Hello</body></html>",
"Hello"},
// missing </head>: don't swallow the body
{ "<html><head><meta charset=\"utf-8\">"
"<body>Hello</body></html>",
"Hello"},
};
for (auto&& test: tests)
assert_equal(html_to_text(test.first), test.second);
}
int
main(int argc, char* argv[])
{
mu_test_init(&argc, &argv);
g_test_add_func("/html-to-text/test-basics", test_basics);
g_test_add_func("/html-to-text/test-quoted", test_quoted);
g_test_add_func("/html-to-text/test-script-style", test_script_style);
g_test_add_func("/html-to-text/test-entities", test_entities);
g_test_add_func("/html-to-text/test-skipped", test_skipped);
return g_test_run();
}
#endif /*BUILD_TESTS*/
#ifdef BUILD_HTML_TO_TEXT
#include "mu-utils-file.hh"
// simple tool that reads html on stdin and outputs text on stdout
// e.g. curl --silent https://www.example.com | build/lib/utils/mu-html2text
int
main (int argc, char *argv[])
{
auto res = read_from_stdin();
if (!res) {
mu_printerrln("error reading from stdin: {}", res.error().what());
return 1;
}
mu_println("{}", html_to_text(*res));
return 0;
}
#endif /*BUILD_HTML_TO_TEXT*/