/* ** Copyright (C) 2026 Dirk-Jan C. Binnema ** ** 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 #include #include #include #include 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(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 B", "A B"}, { "A'; }B", "A B"}, // '//' in css is not a comment { "AB", "A B"}, }; for (auto&& test: tests) assert_equal(html_to_text(test.first), test.second); } static void test_entities() // entities { static std::vector> tests = { // bare '&' is not an entity; keep the text { "Tom & Jerry; forever", "Tom & Jerry; forever"}, { "AT&T <3", "AT&T <3"}, // accents are dropped { "página", "pagina"}, // numeric entities, decimal and hex { "don't don’t", "don't don’t"}, { "café", "café"}, // invalid numeric entities become a space { "A�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> tests = { { "Title" "" "Hello", "Hello"}, // missing : don't swallow the body { "" "Hello", "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*/