Merge pull request #79 from torlando-tech/feat/nomadnet-tables-pr
Build and Deploy Firmware / build-and-deploy (push) Canceled after 0s
Test / Pyxis pytest suite (build_scripts + native) (push) Canceled after 0s
Test / microReticulum native unit tests (PlatformIO native17) (push) Canceled after 0s

feat: add bounded NomadNet table rendering
This commit is contained in:
Torlando
2026-08-16 12:06:28 -04:00
committed by GitHub
9 changed files with 1032 additions and 12 deletions
+85 -2
View File
@@ -37,11 +37,19 @@ bool CompactPage::append_display(const std::string& value, uint32_t& offset, uin
bool CompactPage::assign(const Document& document) {
clear();
try {
const bool reserve_notice = document.truncated;
const bool reserve_notice = document.truncated || document.unsupported;
const std::size_t block_limit = MAX_BLOCKS - (reserve_notice ? 1 : 0);
const std::size_t run_limit = MAX_RUNS - (reserve_notice ? 1 : 0);
const std::size_t block_count = std::min(document.blocks.size(), block_limit);
const std::size_t link_count = std::min(document.links.size(), MAX_LINKS);
const std::size_t table_count = std::min(document.tables.size(), MAX_TABLES);
std::size_t table_cell_count = 0;
for (std::size_t i = 0; i < table_count; ++i) {
const std::size_t cells = static_cast<std::size_t>(document.tables[i].row_count) *
document.tables[i].column_count;
table_cell_count += std::min(cells,
MAX_TABLE_CELLS - std::min(table_cell_count, MAX_TABLE_CELLS));
}
std::size_t anchor_count = 0;
for (const auto& anchor : document.anchors) {
if (anchor_count >= MAX_ANCHORS) break;
@@ -61,6 +69,14 @@ bool CompactPage::assign(const Document& document) {
arena_size += bytes;
}
}
const std::size_t table_run_count = std::min(document.table_runs.size(),
run_limit - std::min(run_count, run_limit));
for (std::size_t r = 0; r < table_run_count; ++r) {
const std::size_t bytes = document.table_runs[r].text.size() + 1;
if (bytes > MAX_ARENA_BYTES - std::min(arena_size, MAX_ARENA_BYTES)) return false;
arena_size += bytes;
}
run_count += table_run_count;
for (std::size_t i = 0; i < link_count; ++i) {
const std::size_t bytes = document.links[i].target.size() +
(document.links[i].fields.empty() ? 0 : document.links[i].fields.size() + 1) + 1;
@@ -82,6 +98,8 @@ bool CompactPage::assign(const Document& document) {
_runs.reserve(std::min(run_count, run_limit));
_links.reserve(link_count);
_anchors.reserve(anchor_count);
_tables.reserve(table_count);
_table_cells.reserve(table_cell_count);
for (std::size_t i = 0; i < link_count; ++i) {
LinkRecord link;
@@ -122,6 +140,9 @@ bool CompactPage::assign(const Document& document) {
block.depth = source_block.depth;
block.alignment = source_block.alignment;
block.divider_codepoint = source_block.divider_codepoint;
block.table_index = source_block.table_index >= 0 &&
static_cast<std::size_t>(source_block.table_index) < table_count
? source_block.table_index : -1;
for (const auto& source_run : source_block.runs) {
if (_runs.size() >= run_limit || block.run_count == std::numeric_limits<uint16_t>::max()) {
_truncated = true;
@@ -148,12 +169,72 @@ bool CompactPage::assign(const Document& document) {
_blocks.push_back(block);
}
for (std::size_t i = 0; i < table_count; ++i) {
const auto& source_table = document.tables[i];
const std::size_t source_cells = static_cast<std::size_t>(source_table.row_count) *
source_table.column_count;
if (source_table.first_cell > document.table_cells.size() ||
source_cells > document.table_cells.size() - source_table.first_cell) {
clear();
return false;
}
TableRecord table;
table.first_cell = static_cast<uint32_t>(_table_cells.size());
table.column_count = source_table.column_count;
table.alignment = source_table.alignment;
table.max_width = source_table.max_width;
const std::size_t cells_left = MAX_TABLE_CELLS - std::min(_table_cells.size(), MAX_TABLE_CELLS);
const std::size_t retained_cells = std::min(source_cells, cells_left);
for (std::size_t c = 0; c < retained_cells; ++c) {
const auto& source_cell = document.table_cells[source_table.first_cell + c];
if (source_cell.first_run > document.table_runs.size() ||
source_cell.run_count > document.table_runs.size() - source_cell.first_run) {
clear();
return false;
}
TableCellRecord cell;
cell.first_run = static_cast<uint32_t>(_runs.size());
cell.alignment = source_cell.alignment;
for (std::size_t r = 0; r < source_cell.run_count; ++r) {
if (_runs.size() >= run_limit) {
_truncated = true;
break;
}
const auto& source_run = document.table_runs[source_cell.first_run + r];
RunRecord run;
if (!append_display(source_run.text, run.text_offset, run.text_length)) {
clear();
return false;
}
run.link_index = source_run.link_index >= 0 &&
static_cast<std::size_t>(source_run.link_index) < _links.size()
? static_cast<int16_t>(source_run.link_index) : -1;
if (source_run.bold) run.style |= BOLD;
if (source_run.italic) run.style |= ITALIC;
if (source_run.underline) run.style |= UNDERLINE;
if (source_run.has_foreground) run.style |= HAS_FOREGROUND;
if (source_run.has_background) run.style |= HAS_BACKGROUND;
run.foreground = source_run.foreground;
run.background = source_run.background;
_runs.push_back(run);
++cell.run_count;
}
_table_cells.push_back(cell);
}
table.row_count = table.column_count == 0 ? 0 :
static_cast<uint16_t>(retained_cells / table.column_count);
if (retained_cells != source_cells) _truncated = true;
_tables.push_back(table);
}
_has_background = document.has_background;
_background = document.background;
_has_foreground = document.has_foreground;
_foreground = document.foreground;
_truncated = _truncated || document.truncated || document.blocks.size() > block_count ||
document.links.size() > link_count || document.anchors.size() > anchor_count;
document.links.size() > link_count || document.anchors.size() > anchor_count ||
document.tables.size() > table_count || document.table_cells.size() > table_cell_count ||
document.table_runs.size() > table_run_count;
_unsupported = document.unsupported;
return true;
} catch (const std::bad_alloc&) {
@@ -168,6 +249,8 @@ void CompactPage::clear() {
ExternalVector<RunRecord>().swap(_runs);
ExternalVector<LinkRecord>().swap(_links);
ExternalVector<AnchorRecord>().swap(_anchors);
ExternalVector<TableRecord>().swap(_tables);
ExternalVector<TableCellRecord>().swap(_table_cells);
_has_background = false;
_background = 0;
_has_foreground = false;
+70 -1
View File
@@ -16,7 +16,55 @@ inline bool layout_content_truncated(std::size_t fragment_count,
}
inline bool block_has_layout_content(BlockType type, uint16_t run_count) {
return type == BlockType::DIVIDER || type == BlockType::HEADING || run_count != 0;
return type == BlockType::DIVIDER || type == BlockType::HEADING ||
type == BlockType::TABLE || run_count != 0;
}
enum class TableLayoutTier : uint8_t { FIT, REFLOW };
inline TableLayoutTier choose_table_layout(int32_t structural_minimum_width,
int32_t content_width) {
return structural_minimum_width <= content_width
? TableLayoutTier::FIT : TableLayoutTier::REFLOW;
}
inline int16_t fit_table_columns(int16_t* widths, uint8_t column_count,
int16_t minimum_width, int16_t target_width) {
if (!widths || column_count == 0 ||
column_count > DocumentParser::MAX_TABLE_COLUMNS || minimum_width <= 0)
return 0;
int32_t natural_width = 0;
for (uint8_t column = 0; column < column_count; ++column) {
widths[column] = std::max<int16_t>(minimum_width, widths[column]);
natural_width += widths[column];
}
const int32_t structural_minimum = static_cast<int32_t>(minimum_width) * column_count;
if (structural_minimum > INT16_MAX) return 0;
const int32_t bounded_target = std::max<int32_t>(
structural_minimum, std::min<int32_t>(target_width, INT16_MAX));
if (natural_width <= bounded_target) return static_cast<int16_t>(natural_width);
uint8_t order[DocumentParser::MAX_TABLE_COLUMNS] = {0};
for (uint8_t column = 0; column < column_count; ++column) {
uint8_t position = column;
while (position > 0 && widths[order[position - 1]] < widths[column]) {
order[position] = order[position - 1];
--position;
}
order[position] = column;
}
int32_t excess = natural_width - bounded_target;
for (uint8_t position = 0; position < column_count && excess > 0; ++position) {
const uint8_t column = order[position];
const int32_t reduction = std::min<int32_t>(
excess, widths[column] - minimum_width);
widths[column] = static_cast<int16_t>(widths[column] - reduction);
excess -= reduction;
}
int32_t fitted_width = 0;
for (uint8_t column = 0; column < column_count; ++column)
fitted_width += widths[column];
return static_cast<int16_t>(fitted_width);
}
inline uint8_t heading_display_level(uint8_t depth) {
@@ -44,6 +92,8 @@ public:
static constexpr std::size_t MAX_RUNS = DocumentParser::MAX_TOTAL_RUNS;
static constexpr std::size_t MAX_LINKS = DocumentParser::MAX_LINKS;
static constexpr std::size_t MAX_ANCHORS = DocumentParser::MAX_ANCHORS;
static constexpr std::size_t MAX_TABLES = DocumentParser::MAX_TABLES;
static constexpr std::size_t MAX_TABLE_CELLS = DocumentParser::MAX_TOTAL_TABLE_CELLS;
// Text runs, link targets, and anchor names originate in the bounded source.
// Link targets also remain visible in runs, while anchor declarations are
// zero-width. Account for both bounded copies and one terminator per record.
@@ -68,6 +118,7 @@ public:
uint8_t depth = 0;
Alignment alignment = Alignment::LEFT;
uint32_t divider_codepoint = 0x2500;
int16_t table_index = -1;
};
struct RunRecord {
@@ -90,6 +141,20 @@ public:
uint16_t block_index = 0;
};
struct TableRecord {
uint32_t first_cell = 0;
uint16_t row_count = 0;
uint8_t column_count = 0;
Alignment alignment = Alignment::LEFT;
uint16_t max_width = DocumentParser::DEFAULT_TABLE_WIDTH;
};
struct TableCellRecord {
uint32_t first_run = 0;
uint16_t run_count = 0;
Alignment alignment = Alignment::LEFT;
};
struct TextView {
const char* value = nullptr;
std::size_t length = 0;
@@ -110,6 +175,8 @@ public:
const ExternalVector<RunRecord>& runs() const { return _runs; }
const ExternalVector<LinkRecord>& links() const { return _links; }
const ExternalVector<AnchorRecord>& anchors() const { return _anchors; }
const ExternalVector<TableRecord>& tables() const { return _tables; }
const ExternalVector<TableCellRecord>& table_cells() const { return _table_cells; }
TextView text(const RunRecord& run) const;
TextView target(std::size_t index) const;
bool find_anchor(const std::string& name, uint16_t& block_index) const;
@@ -131,6 +198,8 @@ private:
ExternalVector<RunRecord> _runs;
ExternalVector<LinkRecord> _links;
ExternalVector<AnchorRecord> _anchors;
ExternalVector<TableRecord> _tables;
ExternalVector<TableCellRecord> _table_cells;
bool _has_background = false;
uint32_t _background = 0;
bool _has_foreground = false;
+261 -4
View File
@@ -294,6 +294,177 @@ void parse_inline(Document& doc, Block& block, const std::string& line, Style& s
block.alignment = style.alignment;
}
std::string trim_table_cell(const std::string& value) {
std::size_t first = 0;
while (first < value.size() && std::isspace(static_cast<unsigned char>(value[first]))) ++first;
std::size_t last = value.size();
while (last > first && std::isspace(static_cast<unsigned char>(value[last - 1]))) --last;
return value.substr(first, last - first);
}
std::vector<std::string> parse_table_row(const std::string& source) {
std::size_t first = 0;
std::size_t last = source.size();
while (first < last && std::isspace(static_cast<unsigned char>(source[first]))) ++first;
while (last > first && std::isspace(static_cast<unsigned char>(source[last - 1]))) --last;
if (first < last && source[first] == '|') ++first;
if (last > first && source[last - 1] == '|') --last;
std::vector<std::string> cells;
std::string current;
bool escaped = false;
for (std::size_t i = first; i < last; ++i) {
const char value = source[i];
if (escaped) {
current.push_back(value);
escaped = false;
} else if (value == '\\') {
escaped = true;
} else if (value == '|') {
cells.push_back(trim_table_cell(current));
current.clear();
} else {
current.push_back(value);
}
}
cells.push_back(trim_table_cell(current));
return cells;
}
Alignment table_cell_alignment(const std::string& source) {
const std::string value = trim_table_cell(source);
if (!value.empty() && value.front() == ':' && value.back() == ':') return Alignment::CENTER;
if (!value.empty() && value.back() == ':') return Alignment::RIGHT;
return Alignment::LEFT;
}
void append_malformed_table(Document& doc, const std::vector<std::string>& lines,
std::size_t& total_runs) {
if (doc.blocks.size() >= DocumentParser::MAX_BLOCKS) {
doc.mark_truncated(TruncationReason::BLOCKS);
return;
}
if (total_runs >= DocumentParser::MAX_TOTAL_RUNS) {
doc.mark_truncated(TruncationReason::TOTAL_RUNS);
return;
}
Block block;
block.type = BlockType::UNSUPPORTED;
Run run;
run.text = "[Malformed table]";
for (std::size_t i = 0; i < lines.size(); ++i) {
const std::string separator = i == 0 ? " " : " | ";
const std::size_t remaining = DocumentParser::MAX_TABLE_FALLBACK_BYTES -
std::min(run.text.size(), DocumentParser::MAX_TABLE_FALLBACK_BYTES);
if (separator.size() + lines[i].size() <= remaining) {
run.text += separator;
run.text += lines[i];
continue;
}
if (remaining > separator.size()) {
run.text += separator;
std::size_t retained = remaining - separator.size();
retained = std::min(retained, lines[i].size());
while (retained > 0 && retained < lines[i].size() &&
(static_cast<unsigned char>(lines[i][retained]) & 0xc0) == 0x80) --retained;
run.text.append(lines[i], 0, retained);
}
doc.mark_truncated(TruncationReason::TABLE_FALLBACK_BYTES);
break;
}
block.runs.push_back(std::move(run));
doc.blocks.push_back(std::move(block));
++total_runs;
doc.malformed = true;
}
bool append_table(Document& doc, const std::vector<std::string>& lines,
Alignment table_alignment, uint16_t max_width,
Style& style, std::size_t& total_runs) {
if (lines.size() < 2) {
append_malformed_table(doc, lines, total_runs);
return false;
}
if (doc.tables.size() >= DocumentParser::MAX_TABLES) {
doc.mark_truncated(TruncationReason::TABLES);
return false;
}
if (doc.blocks.size() >= DocumentParser::MAX_BLOCKS) {
doc.mark_truncated(TruncationReason::BLOCKS);
return false;
}
const auto header = parse_table_row(lines.front());
const auto alignment_cells = parse_table_row(lines[1]);
const std::size_t columns = std::min(header.size(), DocumentParser::MAX_TABLE_COLUMNS);
if (header.size() > columns) doc.mark_truncated(TruncationReason::TABLE_COLUMNS);
if (columns == 0) {
append_malformed_table(doc, lines, total_runs);
return false;
}
Table table;
table.first_cell = static_cast<uint32_t>(doc.table_cells.size());
table.column_count = static_cast<uint8_t>(columns);
table.alignment = table_alignment;
table.max_width = max_width;
const std::size_t source_rows = lines.size() - 1;
std::size_t rows = std::min(source_rows, DocumentParser::MAX_TABLE_ROWS);
if (source_rows > rows) doc.mark_truncated(TruncationReason::TABLE_ROWS);
const std::size_t document_cells_left = DocumentParser::MAX_TOTAL_TABLE_CELLS -
std::min(doc.table_cells.size(), DocumentParser::MAX_TOTAL_TABLE_CELLS);
const std::size_t table_cells_left = std::min(DocumentParser::MAX_TABLE_CELLS, document_cells_left);
const std::size_t cell_bounded_rows = table_cells_left / columns;
if (rows > cell_bounded_rows) {
rows = cell_bounded_rows;
doc.mark_truncated(TruncationReason::TABLE_CELLS);
}
if (rows == 0) return false;
for (std::size_t row = 0; row < rows; ++row) {
const auto cells = row == 0 ? header : parse_table_row(lines[row + 1]);
for (std::size_t column = 0; column < columns; ++column) {
TableCell cell;
cell.first_run = static_cast<uint32_t>(doc.table_runs.size());
cell.alignment = row == 0 ? Alignment::LEFT :
(column < alignment_cells.size() ? table_cell_alignment(alignment_cells[column]) : Alignment::LEFT);
Block parsed;
if (column < cells.size()) {
std::string cell_source = cells[column];
const bool cell_truncated = cell_source.size() > DocumentParser::MAX_TABLE_CELL_BYTES;
const Style style_before_cell = style;
if (cell_truncated) {
std::size_t retained = DocumentParser::MAX_TABLE_CELL_BYTES;
while (retained > 0 && retained < cell_source.size() &&
(static_cast<unsigned char>(cell_source[retained]) & 0xc0) == 0x80) --retained;
cell_source.resize(retained);
doc.mark_truncated(TruncationReason::TABLE_CELL_BYTES);
}
parse_inline(doc, parsed, cell_source, style);
style = style_before_cell;
}
if (total_runs + parsed.runs.size() > DocumentParser::MAX_TOTAL_RUNS) {
parsed.runs.resize(DocumentParser::MAX_TOTAL_RUNS - total_runs);
doc.mark_truncated(TruncationReason::TOTAL_RUNS);
}
for (auto& run : parsed.runs) doc.table_runs.push_back(std::move(run));
cell.run_count = static_cast<uint16_t>(doc.table_runs.size() - cell.first_run);
total_runs += cell.run_count;
doc.table_cells.push_back(cell);
}
++table.row_count;
if (total_runs >= DocumentParser::MAX_TOTAL_RUNS) break;
}
doc.tables.push_back(table);
Block block;
block.type = BlockType::TABLE;
block.table_index = static_cast<int16_t>(doc.tables.size() - 1);
block.alignment = table_alignment;
doc.blocks.push_back(std::move(block));
return true;
}
} // namespace
Document DocumentParser::parse(const std::string& source) const {
@@ -320,6 +491,12 @@ Document DocumentParser::parse(const char* source, std::size_t size) const {
}
Style style;
bool literal = false;
bool table_mode = false;
bool table_alignment_explicit = false;
Alignment table_alignment = Alignment::LEFT;
uint16_t table_width = DEFAULT_TABLE_WIDTH;
std::size_t table_bytes = 0;
std::vector<std::string> table_lines;
uint8_t section_depth = 0;
std::size_t total_runs = 0;
std::size_t offset = 0;
@@ -352,13 +529,13 @@ Document DocumentParser::parse(const char* source, std::size_t size) const {
} else doc.malformed = true;
continue;
}
if (line.rfind("#!bg=", 0) == 0) {
if (!table_mode && line.rfind("#!bg=", 0) == 0) {
uint32_t value = 0;
if (parse_micron_color(line.substr(5), value)) { doc.has_background = true; doc.background = value; }
else doc.malformed = true;
continue;
}
if (line.rfind("#!fg=", 0) == 0) {
if (!table_mode && line.rfind("#!fg=", 0) == 0) {
uint32_t value = 0;
if (parse_micron_color(line.substr(5), value)) { doc.has_foreground = true; doc.foreground = value; }
else doc.malformed = true;
@@ -383,12 +560,71 @@ Document DocumentParser::parse(const char* source, std::size_t size) const {
++total_runs;
continue;
}
if (line.empty()) continue;
bool pre_escaped_table_line = false;
if (!literal && line[0] == '\\' &&
(table_mode || line.rfind("\\`t", 0) == 0)) {
line.erase(0, 1);
pre_escaped_table_line = true;
}
if (!literal && !pre_escaped_table_line && !line.empty() && line[0] == '#') continue;
if (!literal && line.rfind("`t", 0) == 0) {
if (table_mode) {
const bool canonical_table_rendered = table_lines.size() >= 2;
append_table(doc, table_lines, table_alignment, table_width, style, total_runs);
table_mode = false;
table_lines.clear();
table_bytes = 0;
if (table_alignment_explicit && canonical_table_rendered)
style.alignment = Alignment::LEFT;
table_alignment_explicit = false;
table_alignment = style.alignment;
table_width = DEFAULT_TABLE_WIDTH;
} else {
table_mode = true;
table_lines.clear();
table_bytes = 0;
table_alignment_explicit = false;
table_alignment = style.alignment;
table_width = DEFAULT_TABLE_WIDTH;
std::size_t option = 2;
if (option < line.size() && (line[option] == 'l' || line[option] == 'c' || line[option] == 'r')) {
table_alignment_explicit = true;
table_alignment = line[option] == 'c' ? Alignment::CENTER :
line[option] == 'r' ? Alignment::RIGHT : Alignment::LEFT;
++option;
}
if (option < line.size()) {
const char* width_start = line.c_str() + option;
char* width_end = nullptr;
const long long parsed = std::strtoll(width_start, &width_end, 10);
while (width_end && *width_end &&
std::isspace(static_cast<unsigned char>(*width_end))) ++width_end;
if (width_end && width_end != width_start && *width_end == '\0') {
if (parsed < 0) table_width = 1;
else if (parsed > 0) table_width = static_cast<uint16_t>(
std::min<unsigned long long>(
static_cast<unsigned long long>(parsed), MAX_TABLE_WIDTH));
}
}
}
continue;
}
if (!literal && table_mode) {
const std::size_t row_bytes = line.size() + 1;
if (row_bytes <= MAX_TABLE_BYTES - std::min(table_bytes, MAX_TABLE_BYTES)) {
table_lines.push_back(line);
table_bytes += row_bytes;
} else {
doc.mark_truncated(TruncationReason::TABLE_BYTES);
}
continue;
}
while (!line.empty() && line[0] == '<') {
section_depth = 0;
line.erase(0, 1);
}
if (line.empty()) continue;
if (!literal && line[0] == '#') continue;
if (doc.blocks.size() >= MAX_BLOCKS) {
doc.mark_truncated(TruncationReason::BLOCKS);
break;
@@ -418,7 +654,7 @@ Document DocumentParser::parse(const char* source, std::size_t size) const {
line.size() == 1 + codepoint_bytes && codepoint >= 32) {
block.divider_codepoint = codepoint;
}
} else if (line.rfind("`t", 0) == 0 || line.rfind("`{", 0) == 0 || line.find("`<") != std::string::npos) {
} else if (line.rfind("`{", 0) == 0 || line.find("`<") != std::string::npos) {
block.type = BlockType::UNSUPPORTED;
Run run;
run.text = "[Unsupported Micron content]";
@@ -439,6 +675,7 @@ Document DocumentParser::parse(const char* source, std::size_t size) const {
}
if (offset <= retained && doc.source_lines >= MAX_SOURCE_LINES)
doc.mark_truncated(TruncationReason::SOURCE_LINES);
if (table_mode) append_malformed_table(doc, table_lines, total_runs);
if (literal) doc.malformed = true;
return doc;
}
@@ -471,6 +708,26 @@ std::string truncation_notice(const Document& document) {
if (document.has_truncation(TruncationReason::ANCHORS))
return "[Page truncated: more than " +
std::to_string(DocumentParser::MAX_ANCHORS) + " anchors]";
if (document.has_truncation(TruncationReason::TABLE_FALLBACK_BYTES))
return "[Page truncated: malformed table fallback exceeds " +
std::to_string(DocumentParser::MAX_TABLE_FALLBACK_BYTES) + " bytes]";
if (document.has_truncation(TruncationReason::TABLE_CELL_BYTES))
return "[Page truncated: table cell exceeds " +
std::to_string(DocumentParser::MAX_TABLE_CELL_BYTES) + " bytes]";
if (document.has_truncation(TruncationReason::TABLE_BYTES))
return "[Page truncated: table exceeds " +
std::to_string(DocumentParser::MAX_TABLE_BYTES / 1024) + " KiB]";
if (document.has_truncation(TruncationReason::TABLE_COLUMNS))
return "[Page truncated: more than " +
std::to_string(DocumentParser::MAX_TABLE_COLUMNS) + " table columns]";
if (document.has_truncation(TruncationReason::TABLE_ROWS))
return "[Page truncated: more than " +
std::to_string(DocumentParser::MAX_TABLE_ROWS) + " table rows]";
if (document.has_truncation(TruncationReason::TABLE_CELLS))
return "[Page truncated: too many table cells]";
if (document.has_truncation(TruncationReason::TABLES))
return "[Page truncated: more than " +
std::to_string(DocumentParser::MAX_TABLES) + " tables]";
return "[Page truncated to device safety limits]";
}
+36 -1
View File
@@ -7,7 +7,7 @@
namespace UI::LXMF::NomadNet {
enum class BlockType { TEXT, HEADING, DIVIDER, UNSUPPORTED };
enum class BlockType { TEXT, HEADING, DIVIDER, TABLE, UNSUPPORTED };
enum class Alignment { LEFT, CENTER, RIGHT };
enum class TruncationReason : uint16_t {
@@ -20,6 +20,13 @@ enum class TruncationReason : uint16_t {
LINKS = 1 << 6,
ANCHORS = 1 << 7,
ANCHOR_NAME_BYTES = 1 << 8,
TABLES = 1 << 9,
TABLE_ROWS = 1 << 10,
TABLE_COLUMNS = 1 << 11,
TABLE_CELL_BYTES = 1 << 12,
TABLE_BYTES = 1 << 13,
TABLE_CELLS = 1 << 14,
TABLE_FALLBACK_BYTES = 1 << 15,
};
struct Run {
@@ -34,11 +41,26 @@ struct Run {
int link_index = -1;
};
struct TableCell {
uint32_t first_run = 0;
uint16_t run_count = 0;
Alignment alignment = Alignment::LEFT;
};
struct Table {
uint32_t first_cell = 0;
uint16_t row_count = 0;
uint8_t column_count = 0;
Alignment alignment = Alignment::LEFT;
uint16_t max_width = 100;
};
struct Block {
BlockType type = BlockType::TEXT;
uint8_t depth = 0;
Alignment alignment = Alignment::LEFT;
uint32_t divider_codepoint = 0x2500;
int16_t table_index = -1;
std::vector<Run> runs;
};
@@ -61,6 +83,9 @@ struct Document {
std::vector<Block> blocks;
std::vector<Link> links;
std::vector<Anchor> anchors;
std::vector<Table> tables;
std::vector<TableCell> table_cells;
std::vector<Run> table_runs;
uint32_t cache_seconds = 0;
bool has_background = false;
uint32_t background = 0;
@@ -93,6 +118,16 @@ public:
static constexpr std::size_t MAX_LINKS = 128;
static constexpr std::size_t MAX_ANCHORS = 128;
static constexpr std::size_t MAX_ANCHOR_NAME_BYTES = 64;
static constexpr std::size_t MAX_TABLES = 16;
static constexpr std::size_t MAX_TABLE_ROWS = 32;
static constexpr std::size_t MAX_TABLE_COLUMNS = 8;
static constexpr std::size_t MAX_TABLE_CELLS = 256;
static constexpr std::size_t MAX_TOTAL_TABLE_CELLS = 512;
static constexpr std::size_t MAX_TABLE_CELL_BYTES = 512;
static constexpr std::size_t MAX_TABLE_FALLBACK_BYTES = 1024;
static constexpr std::size_t MAX_TABLE_BYTES = 16 * 1024;
static constexpr uint16_t DEFAULT_TABLE_WIDTH = 100;
static constexpr uint16_t MAX_TABLE_WIDTH = UINT16_MAX;
static constexpr uint32_t MAX_CACHE_SECONDS = 7 * 24 * 60 * 60;
Document parse(const std::string& source) const;
+224
View File
@@ -448,6 +448,218 @@ bool NomadNetScreen::commit_line(int32_t line_y,int16_t line_height,
return true;
}
bool NomadNetScreen::layout_table_cell(const NomadNet::CompactPage::TableCellRecord& cell,
int16_t left,int16_t available,int32_t top,
int32_t window_top,int32_t window_bottom,
bool emit,int32_t& height){
if(available<1||cell.first_run>_page.runs().size()||
cell.run_count>_page.runs().size()-cell.first_run)return false;
int16_t x=left;
int16_t line_h=16;
int32_t line_y=top;
bool line_started=false;
_line_layout.clear();
auto finish_line=[&](){
if(emit){
if(!commit_line(line_y,line_h,cell.alignment,left,available,0,
window_top,window_bottom))return false;
}else _line_layout.clear();
line_y+=line_h;
x=left;
line_h=16;
line_started=false;
return true;
};
for(uint16_t r=0;r<cell.run_count;++r){
const uint16_t run_index=static_cast<uint16_t>(cell.first_run+r);
const auto& run=_page.runs()[run_index];
const auto text=_page.text(run);
const lv_font_t* font=page_run_font(run,false);
const int16_t run_h=static_cast<int16_t>(font->line_height+3);
line_h=std::max(line_h,run_h);
std::size_t offset=0;
while(offset<text.size()){
std::size_t end=offset;
const bool whitespace=text[offset]==' '||text[offset]=='\t';
while(end<text.size()&&((text[end]==' '||text[end]=='\t')==whitespace)&&end-offset<255)++end;
if(end==offset)++end;
int16_t fragment_w=static_cast<int16_t>(lv_txt_get_width(text.data()+offset,
static_cast<uint32_t>(end-offset),font,0,LV_TEXT_FLAG_NONE));
if(whitespace&&x+fragment_w>left+available){offset=end;continue;}
if(!whitespace&&x>left&&x+fragment_w>left+available){
if(!finish_line())return false;
line_h=run_h;
}
if(fragment_w>available){
end=offset;
fragment_w=0;
while(end<text.size()&&end-offset<255){
const std::size_t previous=end;
uint32_t next_index=static_cast<uint32_t>(end);
_lv_txt_encoded_next(text.data(),&next_index);
end=next_index;
const int16_t candidate=static_cast<int16_t>(lv_txt_get_width(text.data()+offset,
static_cast<uint32_t>(end-offset),font,0,LV_TEXT_FLAG_NONE));
if(candidate>available&&previous>offset){end=previous;break;}
fragment_w=candidate;
}
}
if(!(whitespace&&x==left)){
if(emit){
if(run.link_index>=0&&static_cast<std::size_t>(run.link_index)<_link_y.size()){
if(_link_y[run.link_index]<0)_link_y[run.link_index]=line_y;
_link_bottom[run.link_index]=std::max(_link_bottom[run.link_index],line_y+run_h);
}
if(!append_line_fragment(LayoutFragment(run_index,static_cast<uint16_t>(offset),
static_cast<uint16_t>(end-offset),run.link_index,x,0,fragment_w,run_h,false)))return false;
}
x=static_cast<int16_t>(x+fragment_w);
line_started=true;
}
offset=end;
}
}
if(line_started||line_y==top){
if(!finish_line())return false;
}
height=std::max<int32_t>(16,line_y-top);
return true;
}
bool NomadNetScreen::layout_table_fit(const NomadNet::CompactPage::TableRecord& table,
const int16_t* column_widths,int16_t table_fit_width,
int32_t& y,int32_t window_top,int32_t window_bottom){
constexpr int16_t content_width=304;
int16_t table_left=table.alignment==NomadNet::Alignment::CENTER?
static_cast<int16_t>((content_width-table_fit_width)/2):
table.alignment==NomadNet::Alignment::RIGHT?
static_cast<int16_t>(content_width-table_fit_width):0;
for(uint16_t row=0;row<table.row_count;++row){
int32_t row_height=16;
for(uint8_t column=0;column<table.column_count;++column){
const std::size_t cell_index=table.first_cell+
static_cast<std::size_t>(row)*table.column_count+column;
if(cell_index>=_page.table_cells().size())return false;
int32_t cell_height=0;
if(!layout_table_cell(_page.table_cells()[cell_index],0,
std::max<int16_t>(1,column_widths[column]-8),0,
window_top,window_bottom,false,cell_height))return false;
row_height=std::max(row_height,cell_height+6);
}
int16_t cell_left=table_left;
for(uint8_t column=0;column<table.column_count;++column){
const std::size_t cell_index=table.first_cell+
static_cast<std::size_t>(row)*table.column_count+column;
if(y+row_height>=window_top&&y<window_bottom){
if(_page_layout.size()>=MAX_WINDOW_FRAGMENTS)return false;
LayoutFragment box(UINT16_MAX,0,0,-1,cell_left,
static_cast<int16_t>(y-window_top),column_widths[column],
static_cast<int16_t>(row_height),false);
box.table_cell=true;
box.table_header=row==0;
_page_layout.push_back(box);
}
int32_t ignored=0;
if(!layout_table_cell(_page.table_cells()[cell_index],
static_cast<int16_t>(cell_left+4),
std::max<int16_t>(1,column_widths[column]-8),y+3,
window_top,window_bottom,true,ignored))return false;
cell_left=static_cast<int16_t>(cell_left+column_widths[column]);
}
y+=row_height;
}
y+=3;
return true;
}
bool NomadNetScreen::layout_table_reflow(const NomadNet::CompactPage::TableRecord& table,
int32_t& y,int32_t window_top,int32_t window_bottom){
constexpr int16_t card_width=304;
constexpr int16_t text_left=4;
constexpr int16_t text_width=296;
const uint16_t data_rows=table.row_count>1?static_cast<uint16_t>(table.row_count-1):1;
for(uint16_t data_row=0;data_row<data_rows;++data_row){
for(uint8_t column=0;column<table.column_count;++column){
const std::size_t header_index=table.first_cell+column;
const std::size_t value_index=table.row_count>1?
table.first_cell+static_cast<std::size_t>(data_row+1)*table.column_count+column:
header_index;
if(header_index>=_page.table_cells().size()||value_index>=_page.table_cells().size())return false;
const std::size_t pair_count=table.row_count>1?2:1;
const std::size_t indices[2]={header_index,value_index};
for(std::size_t part=0;part<pair_count;++part){
int32_t cell_height=0;
if(!layout_table_cell(_page.table_cells()[indices[part]],text_left,text_width,0,
window_top,window_bottom,false,cell_height))return false;
const int32_t box_height=cell_height+6;
if(y+box_height>=window_top&&y<window_bottom){
if(_page_layout.size()>=MAX_WINDOW_FRAGMENTS)return false;
LayoutFragment box(UINT16_MAX,0,0,-1,0,
static_cast<int16_t>(y-window_top),card_width,
static_cast<int16_t>(box_height),false);
box.table_cell=true;
box.table_header=part==0;
_page_layout.push_back(box);
}
int32_t ignored=0;
if(!layout_table_cell(_page.table_cells()[indices[part]],text_left,text_width,y+3,
window_top,window_bottom,true,ignored))return false;
y+=box_height;
}
}
y+=4;
}
return true;
}
bool NomadNetScreen::layout_table(const NomadNet::CompactPage::BlockRecord& block,
int32_t& y,int32_t window_top,int32_t window_bottom){
constexpr int16_t content_width=304;
if(block.table_index<0||static_cast<std::size_t>(block.table_index)>=_page.tables().size())return false;
const auto& table=_page.tables()[block.table_index];
if(table.column_count==0||table.column_count>NomadNet::DocumentParser::MAX_TABLE_COLUMNS||
table.row_count==0)return false;
int16_t column_widths[NomadNet::DocumentParser::MAX_TABLE_COLUMNS]={0};
const int16_t minimum_width=static_cast<int16_t>(lv_txt_get_width(
" ",3,&nomadnet_font_12,0,LV_TEXT_FLAG_NONE)+8);
for(uint8_t column=0;column<table.column_count;++column)column_widths[column]=minimum_width;
for(uint16_t row=0;row<table.row_count;++row){
for(uint8_t column=0;column<table.column_count;++column){
const std::size_t cell_index=table.first_cell+
static_cast<std::size_t>(row)*table.column_count+column;
if(cell_index>=_page.table_cells().size())return false;
const auto& cell=_page.table_cells()[cell_index];
if(cell.first_run>_page.runs().size()||cell.run_count>_page.runs().size()-cell.first_run)return false;
int32_t measured=8;
for(uint16_t r=0;r<cell.run_count;++r){
const auto& run=_page.runs()[cell.first_run+r];
const auto text=_page.text(run);
measured+=lv_txt_get_width(text.data(),static_cast<uint32_t>(text.size()),
page_run_font(run,false),0,LV_TEXT_FLAG_NONE);
measured=std::min<int32_t>(measured,INT16_MAX);
}
column_widths[column]=std::max<int16_t>(column_widths[column],
static_cast<int16_t>(measured));
}
}
int32_t natural_width=0;
for(uint8_t column=0;column<table.column_count;++column)natural_width+=column_widths[column];
const int32_t space_width=std::max<int32_t>(1,lv_txt_get_width(
" ",1,&nomadnet_font_12,0,LV_TEXT_FLAG_NONE));
const int32_t metadata_width=std::min<int32_t>(content_width,
static_cast<int32_t>(table.max_width)*space_width);
const int32_t structural_minimum=static_cast<int32_t>(minimum_width)*table.column_count;
if(NomadNet::choose_table_layout(structural_minimum,content_width)==
NomadNet::TableLayoutTier::FIT){
const int16_t target_width=static_cast<int16_t>(std::max<int32_t>(
structural_minimum,std::min<int32_t>(metadata_width,content_width)));
const int16_t table_fit_width=NomadNet::fit_table_columns(
column_widths,table.column_count,minimum_width,target_width);
return layout_table_fit(table,column_widths,table_fit_width,y,window_top,window_bottom);
}
return layout_table_reflow(table,y,window_top,window_bottom);
}
bool NomadNetScreen::layout_from(std::size_t start_block,int32_t start_y,
int32_t window_top,int32_t window_bottom,
bool build_index){
@@ -472,6 +684,10 @@ bool NomadNetScreen::layout_from(std::size_t start_block,int32_t start_y,
}
y+=divider_height;continue;
}
if(block.type==NomadNet::BlockType::TABLE){
if(!layout_table(block,y,window_top,window_bottom))return false;
continue;
}
const bool heading=block.type==NomadNet::BlockType::HEADING;
const bool has_runs=block.run_count!=0&&block.first_run<_page.runs().size();
if(!heading&&!has_runs)continue;
@@ -648,6 +864,14 @@ void NomadNetScreen::draw_page(lv_event_t* event){
}
continue;
}
if(fragment.table_cell){
lv_draw_rect_dsc_t cell;lv_draw_rect_dsc_init(&cell);
cell.bg_color=fragment.table_header?Theme::surfaceContainer():Theme::surface();
cell.border_color=Theme::border();
cell.border_width=1;
lv_draw_rect(draw_ctx,&cell,&area);
continue;
}
if(fragment.heading_starts_band()){
lv_area_t band_area{content_area.x1,static_cast<lv_coord_t>(draw_y),content_area.x2,
static_cast<lv_coord_t>(draw_y+std::max<int16_t>(fragment.height,1)-1)};
+14 -1
View File
@@ -43,7 +43,7 @@ private:
// Only the visible region plus bounded overscan is retained. The parser
// admits at most 1024 runs, so this also covers a pathological viewport
// containing every styled run plus bounded dividers.
static constexpr std::size_t MAX_WINDOW_FRAGMENTS = 1056;
static constexpr std::size_t MAX_WINDOW_FRAGMENTS = 1600;
static constexpr int32_t MAX_PHYSICAL_SCROLL_EXTENT = 30000;
struct LayoutFragment {
uint16_t run_index = 0;
@@ -57,6 +57,8 @@ private:
uint32_t divider_codepoint = 0x2500;
bool divider = false;
bool large_font = false;
bool table_cell = false;
bool table_header = false;
uint8_t heading_style = 0;
LayoutFragment() = default;
LayoutFragment(uint16_t run, uint16_t offset, uint16_t length, int16_t link,
@@ -111,6 +113,17 @@ private:
void clear_directory();
bool layout_page();
bool layout_window(int32_t logical_scroll);
bool layout_table(const NomadNet::CompactPage::BlockRecord& block,
int32_t& y, int32_t window_top, int32_t window_bottom);
bool layout_table_fit(const NomadNet::CompactPage::TableRecord& table,
const int16_t* column_widths, int16_t table_fit_width,
int32_t& y, int32_t window_top, int32_t window_bottom);
bool layout_table_reflow(const NomadNet::CompactPage::TableRecord& table,
int32_t& y, int32_t window_top, int32_t window_bottom);
bool layout_table_cell(const NomadNet::CompactPage::TableCellRecord& cell,
int16_t left, int16_t available, int32_t top,
int32_t window_top, int32_t window_bottom,
bool emit, int32_t& height);
bool layout_from(std::size_t start_block, int32_t start_y,
int32_t window_top, int32_t window_bottom,
bool build_index);
+11 -2
View File
@@ -2303,8 +2303,17 @@ void UIManager::nomad_update() {
}
nomad_heap_checkpoint("response-normalized");
const auto& bytes = _nomad_response.bytes();
const NomadNet::Document document = _nomad_parser.parse(
reinterpret_cast<const char*>(bytes.data()), bytes.size());
NomadNet::Document document;
try {
document = _nomad_parser.parse(
reinterpret_cast<const char*>(bytes.data()), bytes.size());
} catch (const std::bad_alloc&) {
_nomad_response.clear();
nomad_stop_transport();
LVGL_LOCK();
_nomadnet_screen->set_status("Page is too large for available memory");
break;
}
nomad_heap_checkpoint("response-parsed");
if (!(document.malformed && document.blocks.empty())) {
std::vector<std::string> heading_runs;
+300 -1
View File
@@ -345,6 +345,290 @@ int main(int argc, char** argv) {
compact.has_background() == doc.has_background && compact.background() == doc.background &&
compact.has_foreground() == doc.has_foreground && compact.foreground() == doc.foreground);
const auto table_doc = parser.parse(
"`tc30\n"
"| Name | Price | Qty |\n"
"| ---- | :---: | --: |\n"
"| `F3a3Apple`f | Free | `!5`! |\n"
"| Orange | Ask, nicely | 3 |\n"
"`t\n"
"After table");
check("bounded Micron table syntax produces one semantic table block",
table_doc.tables.size() == 1 && table_doc.blocks.size() == 2 &&
table_doc.blocks[0].type == BlockType::TABLE && table_doc.blocks[0].table_index == 0);
check("table metadata preserves rows columns alignment and maximum width",
table_doc.tables[0].row_count == 3 && table_doc.tables[0].column_count == 3 &&
table_doc.tables[0].alignment == Alignment::CENTER && table_doc.tables[0].max_width == 30);
check("separator alignment applies only to data cells",
table_doc.table_cells.size() == 9 &&
table_doc.table_cells[0].alignment == Alignment::LEFT &&
table_doc.table_cells[3].alignment == Alignment::LEFT &&
table_doc.table_cells[4].alignment == Alignment::CENTER &&
table_doc.table_cells[5].alignment == Alignment::RIGHT);
bool saw_table_color = false;
bool saw_table_bold = false;
for (const auto& run : table_doc.table_runs) {
if (run.text == "Apple") saw_table_color = run.has_foreground && run.foreground == 0x33aa33;
if (run.text == "5") saw_table_bold = run.bold;
}
check("table cells preserve inline formatting", saw_table_color && saw_table_bold);
check("content after a closed table remains ordinary page content",
table_doc.blocks[1].runs.size() == 1 && table_doc.blocks[1].runs[0].text == "After table");
const auto inherited_table_alignment = parser.parse(
"`c\n`t\nH\n---\nV\n`t\nAfter");
check("bare table tags preserve the active canonical alignment",
inherited_table_alignment.tables.size() == 1 &&
inherited_table_alignment.tables[0].alignment == Alignment::CENTER &&
inherited_table_alignment.blocks.back().alignment == Alignment::CENTER);
const auto explicit_table_alignment = parser.parse(
"`r\n`tl\nH\n---\nV\n`t\nAfter");
check("explicit table alignment resets following content to canonical left alignment",
explicit_table_alignment.tables.size() == 1 &&
explicit_table_alignment.tables[0].alignment == Alignment::LEFT &&
explicit_table_alignment.blocks.back().alignment == Alignment::LEFT);
const auto spaced_table_width = parser.parse("`tc 30\nH\n---\nV\n`t");
const auto signed_table_width = parser.parse("`tc+30\nH\n---\nV\n`t");
const auto negative_table_width = parser.parse("`tc-30\nH\n---\nV\n`t");
const auto zero_table_width = parser.parse("`tc0\nH\n---\nV\n`t");
const auto malformed_table_width = parser.parse("`tc30x\nH\n---\nV\n`t");
const auto huge_table_width = parser.parse(
"`tc999999999999999999999999999999999999\nH\n---\nV\n`t");
check("canonical table width conversion is accepted before bounded normalization",
spaced_table_width.tables.size() == 1 && spaced_table_width.tables[0].max_width == 30 &&
signed_table_width.tables.size() == 1 && signed_table_width.tables[0].max_width == 30 &&
negative_table_width.tables[0].max_width == 1 &&
zero_table_width.tables[0].max_width == DocumentParser::DEFAULT_TABLE_WIDTH &&
malformed_table_width.tables[0].max_width == DocumentParser::DEFAULT_TABLE_WIDTH &&
huge_table_width.tables[0].max_width == DocumentParser::MAX_TABLE_WIDTH);
const auto malformed_aligned_table = parser.parse(
"`r\n`tc\nonly one row\n`t\nAfter");
check("malformed explicit tables preserve the prior canonical alignment",
malformed_aligned_table.tables.empty() && malformed_aligned_table.malformed &&
malformed_aligned_table.blocks.back().alignment == Alignment::RIGHT);
const auto blank_comment_table = parser.parse(
"`t\nH\n---\n# ignored\n\nV\n`t");
check("comments are discarded and blank source lines remain outside the canonical table buffer",
blank_comment_table.tables.size() == 1 && blank_comment_table.tables[0].row_count == 2 &&
blank_comment_table.blocks.size() == 2 &&
blank_comment_table.blocks.front().type == BlockType::TEXT &&
blank_comment_table.blocks.back().type == BlockType::TABLE);
const auto table_page_directives = parser.parse(
"`t\nH\n---\n#!bg=f00\n#!fg=0f0\nV\n`t");
check("page color directives inside tables remain canonical comments",
table_page_directives.tables.size() == 1 &&
table_page_directives.tables[0].row_count == 2 &&
!table_page_directives.has_background && !table_page_directives.has_foreground);
const auto linked_table = parser.parse(
"`t\nAction\n---\n`[Open`:/page/details.mu]\n`t");
check("links inside table cells retain normal link semantics",
linked_table.tables.size() == 1 && linked_table.links.size() == 1 &&
linked_table.table_runs.size() == 2 && linked_table.table_runs.back().link_index == 0 &&
linked_table.links[0].target == ":/page/details.mu");
CompactPage linked_table_page;
const bool linked_table_assigned = linked_table_page.assign(linked_table);
const auto linked_target = linked_table_page.target(0);
check("compact table storage preserves cell-link targets",
linked_table_assigned && linked_table_page.links().size() == 1 &&
std::string(linked_target.data(), linked_target.size()) == ":/page/details.mu" &&
!linked_table_page.runs().empty() && linked_table_page.runs().back().link_index == 0);
check("table blocks remain virtual-layout content without ordinary runs",
block_has_layout_content(BlockType::TABLE, 0));
CompactPage table_page;
check("compact page retains bounded table and cell records",
table_page.assign(table_doc) && table_page.tables().size() == 1 &&
table_page.table_cells().size() == 9 && table_page.blocks()[0].table_index == 0);
const auto& compact_apple_cell = table_page.table_cells()[3];
check("compact table cells address formatted runs in the shared arena",
compact_apple_cell.run_count != 0 &&
std::string(table_page.text(table_page.runs()[compact_apple_cell.first_run]).data(),
table_page.text(table_page.runs()[compact_apple_cell.first_run]).size()) == "Apple");
UI::LXMF::NomadNet::Document unsupported_full_table;
unsupported_full_table.unsupported = true;
UI::LXMF::NomadNet::Table full_table;
full_table.first_cell = 0;
full_table.row_count = 1;
full_table.column_count = 1;
unsupported_full_table.tables.push_back(full_table);
UI::LXMF::NomadNet::TableCell full_cell;
full_cell.first_run = 0;
full_cell.run_count = CompactPage::MAX_RUNS;
unsupported_full_table.table_cells.push_back(full_cell);
unsupported_full_table.table_runs.resize(CompactPage::MAX_RUNS);
UI::LXMF::NomadNet::Block full_table_block;
full_table_block.type = BlockType::TABLE;
full_table_block.table_index = 0;
unsupported_full_table.blocks.push_back(full_table_block);
CompactPage unsupported_full_page;
check("unsupported pages reserve notice capacity when table runs fill the global budget",
unsupported_full_page.assign(unsupported_full_table) && unsupported_full_page.truncated() &&
unsupported_full_page.append_notice("[Unsupported Micron content]"));
const auto uneven_table = parser.parse(
"`t\nA|B|C\n---|:---:|---:\none|two\nx|y|z|ignored\n`t");
check("uneven rows are padded or truncated to the header width",
uneven_table.tables.size() == 1 && uneven_table.tables[0].row_count == 3 &&
uneven_table.tables[0].column_count == 3 && uneven_table.table_cells.size() == 9 &&
uneven_table.table_cells[5].run_count == 0);
const auto escaped_table = parser.parse(
"`t\nA\\|B|C\n---|---\nx\\|y|z\n`t");
check("escaped table separators remain cell text",
escaped_table.tables.size() == 1 && !escaped_table.table_runs.empty() &&
escaped_table.table_runs[0].text == "A|B");
const auto preescaped_row_table = parser.parse(
"`t\nLeft|Right\n---|---\n\\|A|B\n`t");
check("canonical leading pre-escape is removed before table-row parsing",
preescaped_row_table.tables.size() == 1 && preescaped_row_table.table_runs.size() == 4 &&
preescaped_row_table.table_runs[2].text == "A" &&
preescaped_row_table.table_runs[3].text == "B");
const auto section_marker_table = parser.parse(
"`t\n<Lead|Other\n---|---\nvalue|second\n`t");
check("table rows retain leading section-marker characters",
section_marker_table.tables.size() == 1 && !section_marker_table.table_runs.empty() &&
section_marker_table.table_runs[0].text == "<Lead");
const auto trailing_escape_table = parser.parse("`t\nA\\\n---\nvalue\n`t");
check("canonical table row parsing drops a trailing escape marker",
trailing_escape_table.tables.size() == 1 && !trailing_escape_table.table_runs.empty() &&
trailing_escape_table.table_runs[0].text == "A");
const auto escaped_closer_table = parser.parse("`t\nH\n---\nvalue\n\\`t\nafter");
check("an escaped table delimiter still toggles canonical table mode",
escaped_closer_table.tables.size() == 1 && escaped_closer_table.blocks.size() == 2 &&
block_text(escaped_closer_table.blocks.back()) == "after");
const auto short_table = parser.parse("`t\nonly one row\n`t\nafter");
check("too-short tables fall back readably and parsing resumes",
short_table.tables.empty() && short_table.malformed && short_table.blocks.size() == 2 &&
block_text(short_table.blocks[0]).find("only one row") != std::string::npos &&
block_text(short_table.blocks[1]) == "after");
const auto unterminated_table = parser.parse("before\n`t\nA|B\n---|---\nx|y");
check("unterminated tables use a bounded readable fallback",
unterminated_table.tables.empty() && unterminated_table.malformed &&
block_text(unterminated_table.blocks.back()).find("A|B") != std::string::npos);
std::string oversized_fallback_source = "`t\n";
oversized_fallback_source += std::string(DocumentParser::MAX_TABLE_FALLBACK_BYTES + 100, 'm');
const auto bounded_fallback = parser.parse(oversized_fallback_source);
check("malformed-table fallback text has an independent byte bound",
bounded_fallback.blocks.size() == 1 &&
block_text(bounded_fallback.blocks.front()).size() <= DocumentParser::MAX_TABLE_FALLBACK_BYTES &&
bounded_fallback.has_truncation(TruncationReason::TABLE_FALLBACK_BYTES));
std::string utf8_fallback_source = "`t\n";
for (std::size_t i = 0; i < DocumentParser::MAX_TABLE_FALLBACK_BYTES; ++i)
utf8_fallback_source += "";
const auto utf8_fallback = parser.parse(utf8_fallback_source);
const std::string retained_fallback = block_text(utf8_fallback.blocks.front());
check("malformed-table fallback clipping preserves complete UTF-8 codepoints",
retained_fallback.size() <= DocumentParser::MAX_TABLE_FALLBACK_BYTES &&
!parser.parse(retained_fallback).malformed);
std::string long_cell(DocumentParser::MAX_TABLE_CELL_BYTES + 8, 'x');
const auto bounded_cell_table = parser.parse(
"`t\nH\n---\n" + long_cell + "\n`t\nafter");
check("table cell bytes are independently bounded without losing following content",
bounded_cell_table.tables.size() == 1 &&
bounded_cell_table.has_truncation(TruncationReason::TABLE_CELL_BYTES) &&
!bounded_cell_table.table_runs.empty() &&
bounded_cell_table.table_runs.back().text.size() == DocumentParser::MAX_TABLE_CELL_BYTES &&
block_text(bounded_cell_table.blocks.back()) == "after");
std::string utf8_cell(DocumentParser::MAX_TABLE_CELL_BYTES - 1, 'u');
utf8_cell += "";
const auto bounded_utf8_cell = parser.parse("`t\nH\n---\n" + utf8_cell + "\n`t");
const std::string retained_cell = bounded_utf8_cell.table_runs.back().text;
check("table-cell clipping preserves complete UTF-8 codepoints",
retained_cell.size() == DocumentParser::MAX_TABLE_CELL_BYTES - 1 &&
!parser.parse(retained_cell).malformed);
const std::string unterminated_bold_cell = "`!" +
std::string(DocumentParser::MAX_TABLE_CELL_BYTES + 20, 'b');
const auto bounded_style_table = parser.parse(
"`t\nStyled|Plain\n---|---\n" + unterminated_bold_cell + "|plain\n`t");
const auto& plain_cell = bounded_style_table.table_cells[3];
check("truncated formatting cannot bleed into the following table cell",
bounded_style_table.tables.size() == 1 && plain_cell.run_count == 1 &&
!bounded_style_table.table_runs[plain_cell.first_run].bold);
const auto isolated_style_table = parser.parse(
"`t\nStyled|Plain\n---|---\n`!bold|plain\n`t\nafter");
const auto& isolated_plain_cell = isolated_style_table.table_cells[3];
check("non-truncated formatting cannot bleed into the following table cell",
isolated_style_table.tables.size() == 1 && isolated_plain_cell.run_count == 1 &&
!isolated_style_table.table_runs[isolated_plain_cell.first_run].bold);
check("table-cell formatting cannot bleed into post-table content",
!isolated_style_table.blocks.back().runs.empty() &&
!isolated_style_table.blocks.back().runs.front().bold);
std::string wide_header;
std::string wide_separator;
for (std::size_t i = 0; i <= DocumentParser::MAX_TABLE_COLUMNS; ++i) {
if (i != 0) { wide_header += '|'; wide_separator += '|'; }
wide_header += "C" + std::to_string(i);
wide_separator += "---";
}
const auto bounded_column_table = parser.parse(
"`t\n" + wide_header + "\n" + wide_separator + "\n`t");
check("table columns retain a deterministic prefix",
bounded_column_table.tables.size() == 1 &&
bounded_column_table.tables[0].column_count == DocumentParser::MAX_TABLE_COLUMNS &&
bounded_column_table.has_truncation(TruncationReason::TABLE_COLUMNS));
std::string tall_table_source = "`t\nH\n---\n";
for (std::size_t i = 0; i < DocumentParser::MAX_TABLE_ROWS; ++i)
tall_table_source += std::to_string(i) + "\n";
tall_table_source += "`t";
const auto bounded_row_table = parser.parse(tall_table_source);
check("table rows retain a deterministic prefix",
bounded_row_table.tables.size() == 1 &&
bounded_row_table.tables[0].row_count == DocumentParser::MAX_TABLE_ROWS &&
bounded_row_table.has_truncation(TruncationReason::TABLE_ROWS));
check("table truncation notices identify the governing row bound",
truncation_notice(bounded_row_table).find("table rows") != std::string::npos);
check("table truncation notices identify the governing cell-byte bound",
truncation_notice(bounded_cell_table).find("table cell") != std::string::npos);
std::string table_byte_source = "`t\nH\n---\n";
for (int i = 0; i < 5; ++i)
table_byte_source += std::string(DocumentParser::MAX_SOURCE_LINE_BYTES, 'q') + "\n";
table_byte_source += "`t\nafter";
const auto bounded_table_bytes = parser.parse(table_byte_source);
check("total table source bytes are independently bounded",
bounded_table_bytes.tables.size() == 1 &&
bounded_table_bytes.has_truncation(TruncationReason::TABLE_BYTES) &&
block_text(bounded_table_bytes.blocks.back()) == "after");
std::string many_tables_source;
for (std::size_t i = 0; i <= DocumentParser::MAX_TABLES; ++i)
many_tables_source += "`t\nH\n---\n`t\n";
many_tables_source += "after";
const auto bounded_table_count = parser.parse(many_tables_source);
check("table count is independently bounded and following text survives",
bounded_table_count.tables.size() == DocumentParser::MAX_TABLES &&
bounded_table_count.has_truncation(TruncationReason::TABLES) &&
block_text(bounded_table_count.blocks.back()) == "after");
std::string cell_bounded_source = "`t\n";
for (std::size_t c = 0; c < DocumentParser::MAX_TABLE_COLUMNS; ++c) {
if (c != 0) cell_bounded_source += '|';
cell_bounded_source += "H";
}
cell_bounded_source += "\n---|---|---|---|---|---|---|---\n";
for (std::size_t row = 0; row < DocumentParser::MAX_TABLE_CELLS /
DocumentParser::MAX_TABLE_COLUMNS; ++row)
cell_bounded_source += "a|b|c|d|e|f|g|h\n";
cell_bounded_source += "`t";
const auto bounded_table_cells = parser.parse(cell_bounded_source);
check("coherent row and column limits bound per-table cell records",
bounded_table_cells.tables.size() == 1 &&
bounded_table_cells.table_cells.size() == DocumentParser::MAX_TABLE_CELLS &&
bounded_table_cells.has_truncation(TruncationReason::TABLE_ROWS));
const auto two_full_tables = parser.parse(cell_bounded_source + "\n" + cell_bounded_source);
CompactPage two_full_tables_page;
check("two parser-valid full tables fit the aggregate compact cell budget",
two_full_tables.tables.size() == 2 &&
two_full_tables.table_cells.size() == DocumentParser::MAX_TOTAL_TABLE_CELLS &&
two_full_tables_page.assign(two_full_tables) &&
two_full_tables_page.tables().size() == 2 &&
two_full_tables_page.table_cells().size() == CompactPage::MAX_TABLE_CELLS &&
two_full_tables_page.tables()[0].row_count > 0 &&
two_full_tables_page.tables()[1].row_count > 0);
const auto anchor_doc = parser.parse(
"First\n"
"`:spot Second\n"
@@ -669,7 +953,7 @@ int main(int argc, char** argv) {
doc.blocks[4].runs[0].text == "`!literal");
bool saw_unsupported = false;
for (const auto& block : doc.blocks) saw_unsupported = saw_unsupported || block.type == BlockType::UNSUPPORTED;
check("unsupported structured content has fallback", doc.unsupported && saw_unsupported);
check("malformed table content has a readable fallback", doc.malformed && saw_unsupported);
auto later_cache = parser.parse("text\n#!c=99999999999999999999\nmore");
check("cache metadata is first-line-only", later_cache.cache_seconds == 0);
@@ -905,6 +1189,21 @@ int main(int argc, char** argv) {
!UI::LXMF::NomadNet::block_has_layout_content(BlockType::TEXT, 0));
check("divider has layout content without a text run",
UI::LXMF::NomadNet::block_has_layout_content(BlockType::DIVIDER, 0));
check("tables keep their columns when the structural minimum fits the device",
UI::LXMF::NomadNet::choose_table_layout(52, 304) ==
UI::LXMF::NomadNet::TableLayoutTier::FIT);
check("natural or authored widths do not turn a structurally fitting table into cards",
UI::LXMF::NomadNet::choose_table_layout(78, 304) ==
UI::LXMF::NomadNet::TableLayoutTier::FIT);
check("only tables whose structural minimum exceeds the content area use reflow",
UI::LXMF::NomadNet::choose_table_layout(305, 304) ==
UI::LXMF::NomadNet::TableLayoutTier::REFLOW);
int16_t fitted_columns[2] = {40, 400};
const int16_t fitted_width = UI::LXMF::NomadNet::fit_table_columns(
fitted_columns, 2, 26, 180);
check("oversized natural columns shrink widest-first like canonical NomadNet without losing columns",
fitted_width == 180 && fitted_columns[0] == 40 && fitted_columns[1] == 140 &&
fitted_columns[0] + fitted_columns[1] == 180);
check("virtual viewport maps short pages without scaling",
VirtualViewport::logical_from_physical(640, 1200, 150, 1200) == 640 &&
@@ -118,6 +118,37 @@ def test_ui_wiring_contract():
assert "set_save_callback" in manager_cpp
def test_nomadnet_table_renderer_is_bounded_and_virtualized():
document_h = (INCLUDE / "NomadNetDocument.h").read_text()
compact_h = (INCLUDE / "NomadNetCompactPage.h").read_text()
screen_h = (INCLUDE / "NomadNetScreen.h").read_text()
screen = (INCLUDE / "NomadNetScreen.cpp").read_text()
manager = (INCLUDE / "UIManager.cpp").read_text()
for bound in ("MAX_TABLES", "MAX_TABLE_ROWS", "MAX_TABLE_COLUMNS",
"MAX_TABLE_CELLS", "MAX_TOTAL_TABLE_CELLS", "MAX_TABLE_CELL_BYTES",
"MAX_TABLE_FALLBACK_BYTES", "MAX_TABLE_BYTES"):
assert bound in document_h
assert "const ExternalVector<TableRecord>& tables()" in compact_h
assert "const ExternalVector<TableCellRecord>& table_cells()" in compact_h
assert "bool layout_table(" in screen_h
assert "table_fit_width" in screen
assert "layout_table_reflow" in screen
assert "fragment.table_cell" in screen
assert "bool line_started=false" in screen
assert "if(line_started||line_y==top)" in screen
layout = screen[screen.index("bool NomadNetScreen::layout_table("):
screen.index("bool NomadNetScreen::layout_from(")]
assert "lv_obj_create" not in layout
assert "lv_label_create" not in layout
parse_call = manager.index("_nomad_parser.parse(")
parse_guard = manager[parse_call - 300:parse_call + 500]
assert "try {" in parse_guard
assert "catch (const std::bad_alloc&)" in parse_guard
assert "Page is too large for available memory" in parse_guard
assert "_nomad_response.clear()" in parse_guard
def test_nomadnet_anchor_navigation_stays_local_and_uses_layout_checkpoints():
document_h = (INCLUDE / "NomadNetDocument.h").read_text()
document = (INCLUDE / "NomadNetDocument.cpp").read_text()