mirror of
https://github.com/torlando-tech/pyxis.git
synced 2026-08-28 23:00:28 +00:00
Merge pull request #79 from torlando-tech/feat/nomadnet-tables-pr
feat: add bounded NomadNet table rendering
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user