From 9d7d1f2df7df49177e7ac610db95af39249a0f1b Mon Sep 17 00:00:00 2001 From: "Michael A. Cojocari" Date: Sun, 23 Aug 2026 21:40:52 -0400 Subject: [PATCH] Add SD home page to web reader Signed-off-by: Michael A. Cojocari --- src/ui-touch/ReaderContent.cpp | 287 +++++++++++++++++++++ src/ui-touch/ReaderContent.h | 24 ++ src/ui-touch/UITask.cpp | 443 +++++++++++++++++++-------------- test/test_reader_content.cpp | 50 ++++ 4 files changed, 612 insertions(+), 192 deletions(-) create mode 100644 src/ui-touch/ReaderContent.cpp create mode 100644 src/ui-touch/ReaderContent.h create mode 100644 test/test_reader_content.cpp diff --git a/src/ui-touch/ReaderContent.cpp b/src/ui-touch/ReaderContent.cpp new file mode 100644 index 0000000..2f1a385 --- /dev/null +++ b/src/ui-touch/ReaderContent.cpp @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ReaderContent.h" + +#include +#include + +namespace ReaderContent { +namespace { + +bool ciPrefix(const char* text, size_t text_len, const char* prefix) { + const size_t prefix_len = strlen(prefix); + if (text_len < prefix_len) return false; + for (size_t i = 0; i < prefix_len; ++i) { + char actual = text[i]; + char expected = prefix[i]; + if (actual >= 'A' && actual <= 'Z') actual += 32; + if (expected >= 'A' && expected <= 'Z') expected += 32; + if (actual != expected) return false; + } + return true; +} + +size_t decodeEntity(const char* text, size_t text_len, char* out, int* wrote) { + static const struct { const char* name; const char* utf8; } named[] = { + {"amp;","&"},{"lt;","<"},{"gt;",">"},{"quot;","\""},{"apos;","'"},{"nbsp;"," "}, + {"mdash;","\xe2\x80\x94"},{"ndash;","\xe2\x80\x93"},{"hellip;","\xe2\x80\xa6"}, + {"lsquo;","\xe2\x80\x98"},{"rsquo;","\xe2\x80\x99"},{"ldquo;","\xe2\x80\x9c"}, + {"rdquo;","\xe2\x80\x9d"},{"copy;","\xc2\xa9"},{"reg;","\xc2\xae"},{"euro;","\xe2\x82\xac"}, + {"deg;","\xc2\xb0"},{"middot;","\xc2\xb7"},{"bull;","\xe2\x80\xa2"},{"trade;","\xe2\x84\xa2"}, + }; + for (const auto& entity : named) { + const size_t name_len = strlen(entity.name); + if (text_len > name_len && strncmp(text + 1, entity.name, name_len) == 0) { + const int width = strlen(entity.utf8); + memcpy(out, entity.utf8, width); + *wrote = width; + return name_len + 1; + } + } + if (text_len > 3 && text[1] == '#') { + long codepoint = 0; + const bool hex = text[2] == 'x' || text[2] == 'X'; + size_t i = hex ? 3 : 2; + const size_t start = i; + for (; i < text_len && text[i] != ';'; ++i) { + const char c = text[i]; + int digit; + if (c >= '0' && c <= '9') digit = c - '0'; + else if (hex && c >= 'a' && c <= 'f') digit = c - 'a' + 10; + else if (hex && c >= 'A' && c <= 'F') digit = c - 'A' + 10; + else { i = start; break; } + codepoint = codepoint * (hex ? 16 : 10) + digit; + } + if (i > start && i < text_len && text[i] == ';' && codepoint > 0 && codepoint <= 0x10FFFF) { + int width = 0; + if (codepoint < 0x80) out[width++] = static_cast(codepoint); + else if (codepoint < 0x800) { + out[width++] = 0xC0 | (codepoint >> 6); + out[width++] = 0x80 | (codepoint & 0x3F); + } else if (codepoint < 0x10000) { + out[width++] = 0xE0 | (codepoint >> 12); + out[width++] = 0x80 | ((codepoint >> 6) & 0x3F); + out[width++] = 0x80 | (codepoint & 0x3F); + } else { + out[width++] = 0xF0 | (codepoint >> 18); + out[width++] = 0x80 | ((codepoint >> 12) & 0x3F); + out[width++] = 0x80 | ((codepoint >> 6) & 0x3F); + out[width++] = 0x80 | (codepoint & 0x3F); + } + *wrote = width; + return i + 1; + } + } + return 0; +} + +bool tagAttribute(const char* tag, size_t tag_len, const char* attribute, + char* out, size_t cap) { + const size_t attribute_len = strlen(attribute); + for (size_t i = 0; i + attribute_len + 1 < tag_len; ++i) { + if (!ciPrefix(tag + i, tag_len - i, attribute)) continue; + if (i > 0 && tag[i - 1] != ' ' && tag[i - 1] != '\t' && + tag[i - 1] != '\r' && tag[i - 1] != '\n') continue; + size_t cursor = i + attribute_len; + while (cursor < tag_len && (tag[cursor] == ' ' || tag[cursor] == '\t')) ++cursor; + if (cursor >= tag_len || tag[cursor] != '=') continue; + ++cursor; + while (cursor < tag_len && (tag[cursor] == ' ' || tag[cursor] == '\t')) ++cursor; + char quote = 0; + if (cursor < tag_len && (tag[cursor] == '"' || tag[cursor] == '\'')) { + quote = tag[cursor++]; + } + size_t written = 0; + while (cursor < tag_len && written + 1 < cap) { + const char c = tag[cursor]; + if (quote ? c == quote : (c == ' ' || c == '>' || c == '\t')) break; + out[written++] = c; + ++cursor; + } + out[written] = 0; + return written > 0; + } + return false; +} + +} // namespace + +bool resolveUrl(const char* base, const char* href, char* out, size_t cap) { + if (!base || !href || !out || cap == 0) return false; + while (*href == ' ') ++href; + const size_t href_len = strlen(href); + if (!href_len || href[0] == '#') return false; + if (ciPrefix(href, href_len, "javascript:") || ciPrefix(href, href_len, "mailto:") || + ciPrefix(href, href_len, "tel:") || ciPrefix(href, href_len, "data:")) return false; + + if (ciPrefix(href, href_len, "http://") || ciPrefix(href, href_len, "https://") || + ciPrefix(href, href_len, "sd:/")) { + snprintf(out, cap, "%s", href); + } else if (ciPrefix(base, strlen(base), "sd:/")) { + if (href[0] == '/' && href[1] == '/') { + snprintf(out, cap, "https:%s", href); + } else if (href[0] == '/') { + snprintf(out, cap, "sd:%s", href); + } else { + const char* last_slash = strrchr(base + 3, '/'); + if (last_slash) snprintf(out, cap, "%.*s%s", static_cast(last_slash - base + 1), base, href); + else snprintf(out, cap, "sd:/%s", href); + } + } else { + const char* scheme_end = strstr(base, "://"); + if (!scheme_end) return false; + const int scheme_len = static_cast(scheme_end - base); + const char* host = scheme_end + 3; + const char* host_end = strchr(host, '/'); + const int host_len = host_end ? static_cast(host_end - host) : static_cast(strlen(host)); + if (href[0] == '/' && href[1] == '/') { + snprintf(out, cap, "%.*s:%s", scheme_len, base, href); + } else if (href[0] == '/') { + snprintf(out, cap, "%.*s://%.*s%s", scheme_len, base, host_len, host, href); + } else { + const char* last_slash = strrchr(base, '/'); + if (last_slash && last_slash > scheme_end + 2) + snprintf(out, cap, "%.*s%s", static_cast(last_slash - base + 1), base, href); + else + snprintf(out, cap, "%.*s://%.*s/%s", scheme_len, base, host_len, host, href); + } + } + char* fragment = strchr(out, '#'); + if (fragment) *fragment = 0; + return out[0] != 0; +} + +size_t htmlToText(const char* html, size_t html_len, + char* out, size_t out_cap, const char* base, + Link* links, size_t link_capacity, size_t* link_count) { + if (link_count) *link_count = 0; + if (!html || !out || out_cap == 0) return 0; + + size_t out_len = 0; + size_t links_len = 0; + int pending_newlines = 0; + bool pending_space = false; + bool started = false; + bool in_anchor = false; + uint32_t anchor_start = 0; + char anchor_href[HREF_CAPACITY] = ""; + auto emit = [&](char c) { if (out_len + 1 < out_cap) out[out_len++] = c; }; + auto flush = [&]() { + if (!started) { + pending_newlines = 0; + pending_space = false; + return; + } + while (pending_newlines > 0) { emit('\n'); --pending_newlines; } + if (pending_space) { emit(' '); pending_space = false; } + }; + auto finishAnchor = [&]() { + if (in_anchor && out_len > anchor_start && links && links_len < link_capacity) { + links[links_len].start = anchor_start; + links[links_len].end = static_cast(out_len); + snprintf(links[links_len].href, sizeof links[links_len].href, "%s", anchor_href); + ++links_len; + } + in_anchor = false; + }; + static const char* blocks[] = { + "p","div","br","li","ul","ol","tr","h1","h2","h3","h4","h5","h6", + "section","article","header","footer","table","blockquote","pre","hr","nav","title","body", nullptr + }; + + size_t i = 0; + while (i < html_len && out_len + 5 < out_cap) { + const char c = html[i]; + if (c == '<') { + if (html_len - i >= 4 && html[i + 1] == '!' && html[i + 2] == '-' && html[i + 3] == '-') { + size_t end = i + 4; + while (end + 2 < html_len && !(html[end] == '-' && html[end + 1] == '-' && html[end + 2] == '>')) ++end; + i = end + 2 < html_len ? end + 3 : html_len; + continue; + } + const bool script = ciPrefix(html + i, html_len - i, "= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z') || + (value >= '0' && value <= '9')) { + name[name_len++] = value >= 'A' && value <= 'Z' ? value + 32 : value; + ++name_cursor; + } else { + break; + } + } + name[name_len] = 0; + size_t tag_end = i + 1; + while (tag_end < html_len && html[tag_end] != '>') ++tag_end; + if (name[0] == 'a' && name[1] == 0) { + finishAnchor(); + if (!closing) { + char raw_href[300]; + if (base && links && + tagAttribute(html + i, (tag_end < html_len ? tag_end : html_len) - i, + "href", raw_href, sizeof raw_href) && + resolveUrl(base, raw_href, anchor_href, sizeof anchor_href)) { + flush(); + in_anchor = true; + anchor_start = static_cast(out_len); + } + } + } + i = tag_end < html_len ? tag_end + 1 : html_len; + for (int block = 0; blocks[block]; ++block) { + if (strcmp(name, blocks[block]) == 0) { + pending_space = false; + if (pending_newlines < 2) ++pending_newlines; + break; + } + } + continue; + } + if (c == '&') { + char entity[8]; + int width = 0; + const size_t used = decodeEntity(html + i, html_len - i, entity, &width); + if (used) { + for (int byte = 0; byte < width; ++byte) { + if (entity[byte] == ' ') { + if (started) pending_space = true; + } else { + flush(); emit(entity[byte]); started = true; + } + } + i += used; + continue; + } + flush(); emit('&'); started = true; ++i; + continue; + } + if (c == ' ' || c == '\t' || c == '\r' || c == '\n') { + if (started) pending_space = true; + ++i; + continue; + } + flush(); emit(c); started = true; ++i; + } + finishAnchor(); + out[out_len < out_cap ? out_len : out_cap - 1] = 0; + if (link_count) *link_count = links_len; + return out_len; +} + +} // namespace ReaderContent \ No newline at end of file diff --git a/src/ui-touch/ReaderContent.h b/src/ui-touch/ReaderContent.h new file mode 100644 index 0000000..2c442fc --- /dev/null +++ b/src/ui-touch/ReaderContent.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +namespace ReaderContent { + +constexpr size_t HREF_CAPACITY = 240; + +struct Link { + uint32_t start; + uint32_t end; + char href[HREF_CAPACITY]; +}; + +bool resolveUrl(const char* base, const char* href, char* out, size_t cap); + +size_t htmlToText(const char* html, size_t html_len, + char* out, size_t out_cap, const char* base, + Link* links, size_t link_capacity, size_t* link_count); + +} // namespace ReaderContent \ No newline at end of file diff --git a/src/ui-touch/UITask.cpp b/src/ui-touch/UITask.cpp index dcdfcd9..faa25d7 100644 --- a/src/ui-touch/UITask.cpp +++ b/src/ui-touch/UITask.cpp @@ -111,6 +111,7 @@ #include "LuaAppHost.h" // sandboxed Lua apps (LUA_APPS.md Phase 1; self-gated on CAP_LUA_APPS) #include "AppPage.h" // shared full-screen app-page chrome (both of the above use it) +#include "ReaderContent.h" // host-tested HTML text extraction + local/network link resolution #if defined(HAS_TOUCH_UI) #include @@ -22162,6 +22163,7 @@ static void openSignalInfoPopup() { // runs on a core-0 worker task so LVGL never blocks; the UI loop polls s_reader_dirty. #if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION) static const char kReaderDefaultUrl[] = "wadamesh.com"; +static const char kReaderHomeUrl[] = "sd:/home.htm"; static lv_obj_t* s_reader_root = nullptr; static lv_obj_t* s_reader_url_ta = nullptr; static lv_obj_t* s_reader_go = nullptr; @@ -22173,13 +22175,19 @@ static char* s_reader_text = nullptr; // PSRAM: extracted text (NUL static volatile bool s_reader_dirty = false; // worker -> UI: text/status changed static volatile bool s_reader_busy = false; // a fetch is in flight static volatile bool s_reader_ok = false; // last fetch produced a page (worker -> UI) +static volatile bool s_reader_home_missing = false; // initial SD probe failed; restore URL-entry state +static volatile bool s_reader_sd_busy = false; // open reader File owns the removable-card VFS +static TaskHandle_t s_reader_sd_owner = nullptr; +static volatile uint32_t s_reader_generation = 0; +static volatile uint32_t s_reader_task_generation = 0; +static char s_reader_pending_url[300] = ""; static char s_reader_msg[110] = ""; // status text (worker writes, UI shows) static TaskHandle_t s_reader_task = nullptr; static bool s_reader_pristine = false; // URL field still holds the untouched default static const size_t kReaderRawCap = 192 * 1024; // max raw HTML we buffer (PSRAM) static const size_t kReaderTextCap = 60 * 1024; // max extracted text we keep // Links found on the page: byte range in s_reader_text + resolved absolute href. -struct ReaderLink { uint32_t start, end; char href[240]; }; +using ReaderLink = ReaderContent::Link; static const int kReaderMaxLinks = 280; static ReaderLink* s_reader_links = nullptr; // PSRAM array, lazily allocated static int s_reader_nlinks = 0; @@ -22194,7 +22202,7 @@ static lv_obj_t* s_reader_back = nullptr; static lv_obj_t* s_reader_fwd = nullptr; static void closeReaderPage(); -static void readerNavigate(const char* in, bool push); +static bool readerNavigate(const char* in, bool push); static void readerUpdateNavButtons(); static void readerRenderBody(); static void readerSetAddrExpanded(bool exp); @@ -22207,135 +22215,107 @@ static bool readerCiPrefix(const char* s, size_t n, const char* pfx) { if (a >= 'A' && a <= 'Z') a += 32; if (b >= 'A' && b <= 'Z') b += 32; if (a != b) return false; } return true; } -// Decode one HTML entity at s[0]=='&'. Writes UTF-8 to out (>=4 bytes), sets *wrote, -// returns consumed input length; 0 if unrecognised (caller emits '&' literally). -static size_t readerEntity(const char* s, size_t n, char* out, int* wrote) { - static const struct { const char* name; const char* utf8; } named[] = { - {"amp;","&"},{"lt;","<"},{"gt;",">"},{"quot;","\""},{"apos;","'"},{"nbsp;"," "}, - {"mdash;","\xe2\x80\x94"},{"ndash;","\xe2\x80\x93"},{"hellip;","\xe2\x80\xa6"}, - {"lsquo;","\xe2\x80\x98"},{"rsquo;","\xe2\x80\x99"},{"ldquo;","\xe2\x80\x9c"}, - {"rdquo;","\xe2\x80\x9d"},{"copy;","\xc2\xa9"},{"reg;","\xc2\xae"},{"euro;","\xe2\x82\xac"}, - {"deg;","\xc2\xb0"},{"middot;","\xc2\xb7"},{"bull;","\xe2\x80\xa2"},{"trade;","\xe2\x84\xa2"}, +// A bounded Stream lets HTTPClient remove chunk framing while writing directly into +// PSRAM. Short-writing at the cap stops oversized pages without allocating a String. +class ReaderBufferStream final : public Stream { + public: + ReaderBufferStream(uint8_t* data, size_t capacity, uint32_t generation) + : data_(data), capacity_(capacity), generation_(generation) {} + using Print::write; + size_t write(uint8_t byte) override { return write(&byte, 1); } + size_t write(const uint8_t* data, size_t size) override { + const size_t before = size_; + const size_t room = size_ < capacity_ ? capacity_ - size_ : 0; + const size_t copied = size < room ? size : room; + if (copied) { + memcpy(data_ + size_, data, copied); + size_ += copied; + if (generation_ == s_reader_generation && (before >> 14) != (size_ >> 14)) { + snprintf(s_reader_msg, sizeof s_reader_msg, "Loading\xe2\x80\xa6 %uk", (unsigned)(size_ / 1024)); + s_reader_dirty = true; + } + } + return copied; + } + int available() override { return 0; } + int read() override { return -1; } + int peek() override { return -1; } + void flush() override {} + size_t size() const { return size_; } + bool full() const { return size_ == capacity_; } + + private: + uint8_t* data_; + size_t capacity_; + uint32_t generation_; + size_t size_ = 0; +}; + +enum class ReaderLocalResult : uint8_t { Ok, NoStorage, NotFound, ReadFailed }; + +static ReaderLocalResult readerReadLocal(const char* url, uint8_t* raw, size_t capacity, + size_t* total, uint32_t generation) { + fs::FS* storage = nullptr; + bool storage_claimed = false; + auto releaseStorage = [&]() { + if (!storage_claimed) return; + s_reader_sd_owner = nullptr; + s_reader_sd_busy = false; + storage_claimed = false; }; - for (auto& e : named) { size_t ln = strlen(e.name); - if (n > ln && strncmp(s + 1, e.name, ln) == 0) { int w = strlen(e.utf8); memcpy(out, e.utf8, w); *wrote = w; return ln + 1; } } - if (n > 3 && s[1] == '#') { // &#NNN; or &#xHH; - long cp = 0; bool hex = (s[2] == 'x' || s[2] == 'X'); size_t i = hex ? 3 : 2, start = i; - for (; i < n && s[i] != ';'; i++) { char c = s[i]; int d; - if (c >= '0' && c <= '9') d = c - '0'; - else if (hex && c >= 'a' && c <= 'f') d = c - 'a' + 10; - else if (hex && c >= 'A' && c <= 'F') d = c - 'A' + 10; else { i = start; break; } - cp = cp * (hex ? 16 : 10) + d; } - if (i > start && i < n && s[i] == ';' && cp > 0 && cp <= 0x10FFFF) { - int w = 0; - if (cp < 0x80) out[w++] = (char)cp; - else if (cp < 0x800) { out[w++] = 0xC0 | (cp >> 6); out[w++] = 0x80 | (cp & 0x3F); } - else if (cp < 0x10000) { out[w++] = 0xE0 | (cp >> 12); out[w++] = 0x80 | ((cp >> 6) & 0x3F); out[w++] = 0x80 | (cp & 0x3F); } - else { out[w++] = 0xF0 | (cp >> 18); out[w++] = 0x80 | ((cp >> 12) & 0x3F); out[w++] = 0x80 | ((cp >> 6) & 0x3F); out[w++] = 0x80 | (cp & 0x3F); } - *wrote = w; return i + 1; - } +#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || defined(HAS_THINKNODE_M9) || defined(HELTEC_LORA_V4_R8) + s_reader_sd_busy = true; + s_reader_sd_owner = xTaskGetCurrentTaskHandle(); + storage_claimed = true; + if (sdAdoptLiveMount()) storage = &SD; + else if (!sdRuntimeLifecycleBusy() && fmSdTryMount()) storage = &SD; +#elif defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4) + s_reader_sd_busy = true; + s_reader_sd_owner = xTaskGetCurrentTaskHandle(); + storage_claimed = true; + if (tanSdTryMount()) storage = &SD_MMC; +#endif + if (!storage) { releaseStorage(); return ReaderLocalResult::NoStorage; } + + char path[300]; + snprintf(path, sizeof path, "%s", url + 3); + char* query = strchr(path, '?'); + if (query) *query = 0; + if (path[0] != '/') { releaseStorage(); return ReaderLocalResult::NotFound; } + + markSdIo(); + File file = storage->open(path, "r"); + if (!file || file.isDirectory()) { + if (file) file.close(); + releaseStorage(); + return ReaderLocalResult::NotFound; } - return 0; -} -// Extract attribute `attr` (e.g. "href") from a tag body h[0..len) into out. Handles -// quoted ("..", '..') and unquoted values. Returns true if found. -static bool readerTagAttr(const char* h, size_t len, const char* attr, char* out, size_t cap) { - size_t al = strlen(attr); - for (size_t i = 0; i + al + 1 < len; i++) { - if (!readerCiPrefix(h + i, len - i, attr)) continue; - size_t j = i + al; while (j < len && (h[j] == ' ' || h[j] == '\t')) j++; - if (j >= len || h[j] != '=') continue; - j++; while (j < len && (h[j] == ' ' || h[j] == '\t')) j++; - char q = 0; if (j < len && (h[j] == '"' || h[j] == '\'')) { q = h[j]; j++; } - size_t o = 0; - while (j < len && o + 1 < cap) { char c = h[j]; if (q ? (c == q) : (c == ' ' || c == '>' || c == '\t')) break; out[o++] = c; j++; } - out[o] = 0; return o > 0; - } - return false; -} -// Resolve href against base into out (absolute). Returns false for unusable schemes -// (#fragment, javascript:, mailto:, tel:, data:). -static bool readerResolveUrl(const char* base, const char* href, char* out, size_t cap) { - while (*href == ' ') href++; - size_t hl = strlen(href); - if (!hl || href[0] == '#') return false; - if (readerCiPrefix(href, hl, "javascript:") || readerCiPrefix(href, hl, "mailto:") || - readerCiPrefix(href, hl, "tel:") || readerCiPrefix(href, hl, "data:")) return false; - if (readerCiPrefix(href, hl, "http://") || readerCiPrefix(href, hl, "https://")) { snprintf(out, cap, "%s", href); } - else { - const char* se = strstr(base, "://"); if (!se) return false; - int sl = (int)(se - base); const char* host = se + 3; - const char* he = strchr(host, '/'); int hlen = he ? (int)(he - host) : (int)strlen(host); - if (href[0] == '/' && href[1] == '/') snprintf(out, cap, "%.*s:%s", sl, base, href); // //host/path - else if (href[0] == '/') snprintf(out, cap, "%.*s://%.*s%s", sl, base, hlen, host, href); // root-relative - else { // relative to base dir - const char* ls = strrchr(base, '/'); - if (ls && ls > se + 2) snprintf(out, cap, "%.*s%s", (int)(ls - base + 1), base, href); - else snprintf(out, cap, "%.*s://%.*s/%s", sl, base, hlen, host, href); + + bool read_failed = false; + while (*total < capacity && file.available()) { + const size_t request = ((capacity - *total) < 4096) ? (capacity - *total) : 4096; + const int read_count = file.read(raw + *total, request); + if (read_count <= 0) { read_failed = true; break; } + *total += (size_t)read_count; + if (generation == s_reader_generation && ((*total) & 0x3FFF) == 0) { + snprintf(s_reader_msg, sizeof s_reader_msg, "Opening SD page\xe2\x80\xa6 %uk", (unsigned)(*total / 1024)); + s_reader_dirty = true; } + vTaskDelay(1); } - char* frag = strchr(out, '#'); if (frag) *frag = 0; - return out[0] != 0; + file.close(); + releaseStorage(); + return read_failed ? ReaderLocalResult::ReadFailed : ReaderLocalResult::Ok; } -// One-pass HTML -> text. Drops comments + "; + char text[256]; + ReaderContent::Link links[4] = {}; + size_t link_count = 0; + const size_t text_len = ReaderContent::htmlToText( + html, strlen(html), text, sizeof text, "sd:/home.htm", + links, 4, &link_count); + + assert(text_len == strlen(text)); + assert(strstr(text, "Bookmarks") != nullptr); + assert(strstr(text, "CNN") != nullptr); + assert(strstr(text, "Local news & weather") != nullptr); + assert(strstr(text, "12345") == nullptr); + assert(link_count == 2); + assert(strcmp(links[0].href, "https://lite.cnn.com") == 0); + assert(strcmp(links[1].href, "sd:/news.htm") == 0); + assert(strncmp(text + links[0].start, "CNN", links[0].end - links[0].start) == 0); + assert(strncmp(text + links[1].start, "Local news", links[1].end - links[1].start) == 0); + + char resolved[240]; + assert(ReaderContent::resolveUrl("sd:/bookmarks/home.htm", "../index.htm", + resolved, sizeof resolved)); + assert(strcmp(resolved, "sd:/bookmarks/../index.htm") == 0); + assert(ReaderContent::resolveUrl("sd:/home.htm", "//text.npr.org", + resolved, sizeof resolved)); + assert(strcmp(resolved, "https://text.npr.org") == 0); + assert(!ReaderContent::resolveUrl("sd:/home.htm", "javascript:alert(1)", + resolved, sizeof resolved)); + + static const char attribute_html[] = + "Right"; + link_count = 0; + ReaderContent::htmlToText(attribute_html, strlen(attribute_html), text, sizeof text, + "sd:/home.htm", links, 4, &link_count); + assert(link_count == 1); + assert(strcmp(links[0].href, "sd:/right.htm") == 0); + return 0; +} \ No newline at end of file