Adapt table implementation from crossink

This commit is contained in:
jpirnay
2026-05-07 21:33:35 +02:00
parent 1ca18c4575
commit ca9475f0c3
8 changed files with 609 additions and 76 deletions
+134
View File
@@ -1,5 +1,6 @@
#include "Page.h"
#include <GfxRenderer.h>
#include <Logging.h>
#include <Serialization.h>
@@ -48,6 +49,135 @@ std::unique_ptr<PageImage> PageImage::deserialize(FsFile& file) {
return std::unique_ptr<PageImage>(new PageImage(std::move(ib), xPos, yPos));
}
void PageTableFragment::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) {
const int drawX = xPos + xOffset;
const int drawY = yPos + yOffset;
// Outer border
renderer.drawRect(drawX, drawY, totalWidth, totalHeight, true);
// Vertical column separators
int colX = drawX;
for (uint8_t c = 0; c < columnCount - 1; c++) {
colX += colWidths[c];
renderer.drawLine(colX, drawY, colX, drawY + totalHeight - 1, true);
}
// Rows: text content + horizontal separators
int rowY = drawY;
for (size_t r = 0; r < rows.size(); r++) {
const TableRow& row = rows[r];
int cellX = drawX;
for (uint8_t c = 0; c < columnCount && c < static_cast<uint8_t>(row.cells.size()); c++) {
const TableCell& cell = row.cells[c];
int lineY = rowY + TABLE_CELL_PADDING;
for (const auto& line : cell.lines) {
line->render(renderer, fontId, cellX + TABLE_CELL_PADDING, lineY);
lineY += renderer.getLineHeight(fontId);
}
cellX += colWidths[c];
}
rowY += row.height;
// Draw horizontal separator (skip after last row — outer border covers it)
if (r + 1 < rows.size()) {
const int sepLineWidth = row.isHeaderRow ? 2 : 1;
renderer.drawLine(drawX, rowY, drawX + totalWidth - 1, rowY, sepLineWidth, true);
}
}
}
bool PageTableFragment::serialize(FsFile& file) {
serialization::writePod(file, xPos);
serialization::writePod(file, yPos);
serialization::writePod(file, columnCount);
serialization::writePod(file, totalWidth);
serialization::writePod(file, totalHeight);
for (uint8_t c = 0; c < MAX_TABLE_COLS; c++) {
serialization::writePod(file, colWidths[c]);
}
const uint16_t rowCount = static_cast<uint16_t>(rows.size());
serialization::writePod(file, rowCount);
for (const auto& row : rows) {
serialization::writePod(file, row.height);
serialization::writePod(file, row.isHeaderRow);
const uint8_t cellCount = static_cast<uint8_t>(row.cells.size());
serialization::writePod(file, cellCount);
for (const auto& cell : row.cells) {
serialization::writePod(file, cell.isHeader);
const uint8_t lineCount = static_cast<uint8_t>(cell.lines.size());
serialization::writePod(file, lineCount);
for (const auto& line : cell.lines) {
if (!line->serialize(file)) return false;
}
}
}
return true;
}
std::unique_ptr<PageTableFragment> PageTableFragment::deserialize(FsFile& file) {
int16_t xPos, yPos;
serialization::readPod(file, xPos);
serialization::readPod(file, yPos);
uint8_t columnCount;
uint16_t totalWidth, totalHeight;
serialization::readPod(file, columnCount);
serialization::readPod(file, totalWidth);
serialization::readPod(file, totalHeight);
if (columnCount == 0 || columnCount > MAX_TABLE_COLS) {
LOG_ERR("PGE", "TableFragment: invalid columnCount %u", columnCount);
return nullptr;
}
std::array<uint16_t, MAX_TABLE_COLS> colWidths = {};
for (uint8_t c = 0; c < MAX_TABLE_COLS; c++) {
serialization::readPod(file, colWidths[c]);
}
uint16_t rowCount;
serialization::readPod(file, rowCount);
if (rowCount > MAX_TABLE_ROWS) {
LOG_ERR("PGE", "TableFragment: invalid rowCount %u", rowCount);
return nullptr;
}
std::vector<TableRow> rows;
rows.reserve(rowCount);
for (uint16_t r = 0; r < rowCount; r++) {
TableRow row;
serialization::readPod(file, row.height);
serialization::readPod(file, row.isHeaderRow);
uint8_t cellCount;
serialization::readPod(file, cellCount);
if (cellCount > MAX_TABLE_COLS) {
LOG_ERR("PGE", "TableFragment: invalid cellCount %u in row %u", cellCount, r);
return nullptr;
}
row.cells.reserve(cellCount);
for (uint8_t c = 0; c < cellCount; c++) {
TableCell cell;
serialization::readPod(file, cell.isHeader);
uint8_t lineCount;
serialization::readPod(file, lineCount);
cell.lines.reserve(lineCount);
for (uint8_t l = 0; l < lineCount; l++) {
auto tb = TextBlock::deserialize(file);
if (!tb) {
LOG_ERR("PGE", "TableFragment: TextBlock deserialize failed at row %u cell %u line %u", r, c, l);
return nullptr;
}
cell.lines.push_back(std::move(tb));
}
row.cells.push_back(std::move(cell));
}
rows.push_back(std::move(row));
}
return std::unique_ptr<PageTableFragment>(
new PageTableFragment(columnCount, totalWidth, totalHeight, colWidths, std::move(rows), xPos, yPos));
}
void Page::render(GfxRenderer& renderer, const int fontId, const int xOffset, const int yOffset) const {
for (auto& element : elements) {
element->render(renderer, fontId, xOffset, yOffset);
@@ -99,6 +229,10 @@ std::unique_ptr<Page> Page::deserialize(FsFile& file) {
} else if (tag == TAG_PageImage) {
auto pi = PageImage::deserialize(file);
page->elements.push_back(std::move(pi));
} else if (tag == TAG_PageTable) {
auto pt = PageTableFragment::deserialize(file);
if (!pt) return nullptr;
page->elements.push_back(std::move(pt));
} else {
LOG_ERR("PGE", "Deserialization failed: Unknown tag %u", tag);
return nullptr;
+45 -1
View File
@@ -2,6 +2,7 @@
#include <HalStorage.h>
#include <algorithm>
#include <array>
#include <string>
#include <utility>
#include <vector>
@@ -10,9 +11,16 @@
#include "blocks/ImageBlock.h"
#include "blocks/TextBlock.h"
static constexpr uint8_t MAX_TABLE_COLS = 8;
static constexpr uint16_t MAX_TABLE_ROWS = 48;
static constexpr uint8_t TABLE_CELL_PADDING = 5;
static constexpr uint16_t MIN_COL_INNER_WIDTH = 24;
static constexpr uint8_t MAX_CELL_LINES = 64;
enum PageElementTag : uint8_t {
TAG_PageLine = 1,
TAG_PageImage = 2, // New tag
TAG_PageImage = 2,
TAG_PageTable = 3,
};
// represents something that has been added to a page
@@ -55,6 +63,42 @@ class PageImage final : public PageElement {
const ImageBlock& getImageBlock() const { return *imageBlock; }
};
struct TableCell {
std::vector<std::shared_ptr<TextBlock>> lines;
bool isHeader = false;
};
struct TableRow {
std::vector<TableCell> cells;
uint16_t height = 0; // pixel height including 2×CELL_PADDING
bool isHeaderRow = false; // drives 2px separator below this row
};
class PageTableFragment final : public PageElement {
uint8_t columnCount = 0;
uint16_t totalWidth = 0;
uint16_t totalHeight = 0;
std::array<uint16_t, MAX_TABLE_COLS> colWidths = {};
std::vector<TableRow> rows;
public:
PageTableFragment(uint8_t colCount, uint16_t totalWidth, uint16_t totalHeight,
std::array<uint16_t, MAX_TABLE_COLS> colWidths, std::vector<TableRow> rows, int16_t xPos,
int16_t yPos)
: PageElement(xPos, yPos),
columnCount(colCount),
totalWidth(totalWidth),
totalHeight(totalHeight),
colWidths(colWidths),
rows(std::move(rows)) {}
void render(GfxRenderer& renderer, int fontId, int xOffset, int yOffset) override;
bool serialize(FsFile& file) override;
static std::unique_ptr<PageTableFragment> deserialize(FsFile& file);
PageElementTag getTag() const override { return TAG_PageTable; }
uint16_t getTotalHeight() const { return totalHeight; }
};
class Page {
public:
// the list of block index and line numbers on this page
+1 -1
View File
@@ -12,7 +12,7 @@
#include "parsers/ChapterHtmlSlimParser.h"
namespace {
constexpr uint8_t SECTION_FILE_VERSION = 24;
constexpr uint8_t SECTION_FILE_VERSION = 25;
constexpr uint32_t HEADER_SIZE = sizeof(uint8_t) + // SECTION_FILE_VERSION
sizeof(int) + // fontId
sizeof(float) + // lineCompression
+281 -69
View File
@@ -188,25 +188,29 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() {
fontStyle = static_cast<EpdFontFamily::Style>(fontStyle | EpdFontFamily::STRIKETHROUGH);
}
// flush the buffer
// flush the buffer — route to table cell text when inside a <td>/<th>
partWordBuffer[partWordBufferIndex] = '\0';
currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues);
if (currentTableCell) {
currentTableCell->text->addWord(partWordBuffer, fontStyle, false, nextWordContinues);
} else if (currentTextBlock) {
currentTextBlock->addWord(partWordBuffer, fontStyle, false, nextWordContinues);
if (currentTextBlock->size() > 96) {
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
const int horizontalInset = currentTextBlock->getBlockStyle().totalHorizontalInset();
const uint16_t effectiveWidth =
(horizontalInset < viewportWidth) ? static_cast<uint16_t>(viewportWidth - horizontalInset) : viewportWidth;
currentTextBlock->layoutAndExtractLines(
renderer, fontId, effectiveWidth,
[this](const std::shared_ptr<TextBlock>& textBlock, const bool lineEndsWithHyphenatedWord,
const bool suppressHyphenationRetry) {
return addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry);
},
false);
}
}
partWordBufferIndex = 0;
nextWordContinues = false;
if (currentTextBlock->size() > 96) {
LOG_DBG("EHP", "Text block too long, splitting into multiple pages");
const int horizontalInset = currentTextBlock->getBlockStyle().totalHorizontalInset();
const uint16_t effectiveWidth =
(horizontalInset < viewportWidth) ? static_cast<uint16_t>(viewportWidth - horizontalInset) : viewportWidth;
currentTextBlock->layoutAndExtractLines(
renderer, fontId, effectiveWidth,
[this](const std::shared_ptr<TextBlock>& textBlock, const bool lineEndsWithHyphenatedWord,
const bool suppressHyphenationRetry) {
return addLineToPage(textBlock, lineEndsWithHyphenatedWord, suppressHyphenationRetry);
},
false);
}
}
// Emit the current page, keeping paragraphLutPerPage and completedPageCount in lockstep.
@@ -341,65 +345,54 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
return;
}
// Special handling for tables/cells: flatten into per-cell paragraphs with a prefixed header.
// Buffered table rendering: accumulate cells in memory, emit as PageTableFragment on </table>.
if (strcmp(name, "table") == 0) {
// skip nested tables
if (self->tableDepth > 0) {
self->tableDepth += 1;
if (self->currentTable) {
// Nested table — mark unsupported and track depth
self->currentTable->depth += 1;
self->currentTable->unsupported = true;
self->depth += 1;
return;
}
// Flush any pending text before starting the table
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
self->tableDepth += 1;
self->tableRowIndex = 0;
self->tableColIndex = 0;
if (self->currentTextBlock && !self->currentTextBlock->isEmpty()) {
self->makePages();
}
self->currentTable = std::unique_ptr<BufferedTable>(new BufferedTable());
self->currentTable->depth = 1;
self->depth += 1;
return;
}
if (self->tableDepth == 1 && strcmp(name, "tr") == 0) {
self->tableRowIndex += 1;
self->tableColIndex = 0;
if (self->currentTable && self->currentTable->depth == 1 && strcmp(name, "tr") == 0) {
self->currentTable->rows.emplace_back();
if (self->currentTable->rows.size() > MAX_TABLE_ROWS) {
self->currentTable->unsupported = true;
}
self->depth += 1;
return;
}
if (self->tableDepth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) {
if (self->currentTable && self->currentTable->depth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) {
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
self->tableColIndex += 1;
auto tableCellBlockStyle = BlockStyle();
tableCellBlockStyle.textAlignDefined = true;
const auto align = (self->paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(self->paragraphAlignment);
tableCellBlockStyle.alignment = align;
self->startNewTextBlock(tableCellBlockStyle);
const std::string headerText =
"Tab Row " + std::to_string(self->tableRowIndex) + ", Cell " + std::to_string(self->tableColIndex) + ":";
StyleStackEntry headerStyle;
headerStyle.depth = self->depth;
headerStyle.hasBold = true;
headerStyle.bold = false;
headerStyle.hasItalic = true;
headerStyle.italic = true;
headerStyle.hasUnderline = true;
headerStyle.underline = false;
self->inlineStyleStack.push_back(headerStyle);
self->updateEffectiveInlineStyle();
self->characterData(userData, headerText.c_str(), static_cast<int>(headerText.length()));
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
if (self->currentTable->rows.empty()) {
self->currentTable->rows.emplace_back();
}
self->nextWordContinues = false;
self->inlineStyleStack.pop_back();
self->updateEffectiveInlineStyle();
BufferedTableRow& row = self->currentTable->rows.back();
if (row.cells.size() >= MAX_TABLE_COLS) {
self->currentTable->unsupported = true;
}
const bool isHeader = (strcmp(name, "th") == 0);
row.cells.emplace_back();
row.cells.back().isHeader = isHeader;
row.cells.back().text =
std::unique_ptr<ParsedText>(new ParsedText(false, false)); // no paragraph spacing, no hyphenation in cells
self->currentTableCell = &row.cells.back();
self->depth += 1;
return;
}
@@ -1093,11 +1086,18 @@ void XMLCALL ChapterHtmlSlimParser::startElement(void* userData, const XML_Char*
void XMLCALL ChapterHtmlSlimParser::characterData(void* userData, const XML_Char* s, const int len) {
auto* self = static_cast<ChapterHtmlSlimParser*>(userData);
// Skip content of nested table
if (self->tableDepth > 1) {
// Skip content of nested tables (depth > 1 means we're inside a nested table)
if (self->currentTable && self->currentTable->depth > 1) {
return;
}
// Route character data into the active table cell's ParsedText
if (self->currentTableCell) {
// Use the existing partWordBuffer + word-level accumulation logic below,
// but the flush target will be currentTableCell->text (handled in flushPartWordBuffer).
// Fall through to the normal character accumulation path.
}
// Middle of skip
if (self->skipUntilDepth < self->depth) {
return;
@@ -1312,11 +1312,10 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
const bool headerOrBlockTag = isHeaderOrBlock(name);
const bool tableStructuralTag = isTableStructuralTag(name);
if (self->tableDepth > 1 && strcmp(name, "table") == 0) {
// get rid of all text inside the nested table
if (self->currentTable && self->currentTable->depth > 1 && strcmp(name, "table") == 0) {
self->partWordBufferIndex = 0;
self->tableDepth -= 1;
LOG_DBG("EHP", "nested table detected, get rid of its content");
self->currentTable->depth -= 1;
LOG_DBG("EHP", "nested table end, depth now %d", self->currentTable->depth);
return;
}
@@ -1372,18 +1371,37 @@ void XMLCALL ChapterHtmlSlimParser::endElement(void* userData, const XML_Char* n
self->skipTextUntilDepth = INT_MAX;
}
if (self->tableDepth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) {
if (self->currentTable && self->currentTable->depth == 1 && (strcmp(name, "td") == 0 || strcmp(name, "th") == 0)) {
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
// Determine if the whole row consists of header cells
if (!self->currentTable->rows.empty()) {
auto& row = self->currentTable->rows.back();
bool allHeaders = !row.cells.empty();
for (const auto& c : row.cells) {
if (!c.isHeader) {
allHeaders = false;
break;
}
}
row.isHeaderRow = allHeaders;
}
self->currentTableCell = nullptr;
self->nextWordContinues = false;
}
if (self->tableDepth == 1 && (strcmp(name, "tr") == 0)) {
if (self->currentTable && self->currentTable->depth == 1 && strcmp(name, "tr") == 0) {
self->nextWordContinues = false;
}
if (self->tableDepth == 1 && strcmp(name, "table") == 0) {
self->tableDepth -= 1;
self->tableRowIndex = 0;
self->tableColIndex = 0;
if (self->currentTable && self->currentTable->depth == 1 && strcmp(name, "table") == 0) {
if (self->partWordBufferIndex > 0) {
self->flushPartWordBuffer();
}
self->currentTableCell = nullptr;
self->emitBufferedTable();
self->currentTable.reset();
self->nextWordContinues = false;
}
@@ -1693,3 +1711,197 @@ void ChapterHtmlSlimParser::makePages() {
currentPageNextY += lineHeight / 2;
}
}
// Guard: minimum free heap before attempting table layout (cell wrapping allocates TextBlock vectors)
static constexpr size_t MIN_FREE_HEAP_FOR_TABLE = 20 * 1024;
void ChapterHtmlSlimParser::emitBufferedTable() {
if (!currentTable) return;
if (currentTable->unsupported || currentTable->rows.empty()) {
LOG_DBG("EHP", "Table unsupported or empty — falling back to paragraph mode");
emitTableAsParagraphs(*currentTable);
return;
}
if (ESP.getFreeHeap() < MIN_FREE_HEAP_FOR_TABLE) {
LOG_ERR("EHP", "Low heap (%u), falling back to paragraph mode for table", ESP.getFreeHeap());
emitTableAsParagraphs(*currentTable);
return;
}
emitTableAsFragments(*currentTable);
}
void ChapterHtmlSlimParser::emitTableAsFragments(BufferedTable& table) {
// Determine column count (max cells in any row)
uint8_t columnCount = 0;
for (const auto& row : table.rows) {
if (row.cells.size() > columnCount) {
columnCount = static_cast<uint8_t>(row.cells.size());
}
}
if (columnCount == 0 || columnCount > MAX_TABLE_COLS) {
emitTableAsParagraphs(table);
return;
}
const uint16_t totalWidth = viewportWidth;
const uint16_t colWidth = totalWidth / columnCount;
const uint16_t innerColWidth =
(colWidth > 2 * TABLE_CELL_PADDING) ? static_cast<uint16_t>(colWidth - 2 * TABLE_CELL_PADDING) : 0;
if (innerColWidth < MIN_COL_INNER_WIDTH) {
LOG_DBG("EHP", "Table columns too narrow (%u px inner) — falling back to paragraphs", innerColWidth);
emitTableAsParagraphs(table);
return;
}
std::array<uint16_t, MAX_TABLE_COLS> colWidths = {};
for (uint8_t c = 0; c < columnCount; c++) {
colWidths[c] = colWidth;
}
const int lineHeight = static_cast<int>(renderer.getLineHeight(fontId) * lineCompression + 0.5f);
// Pre-wrap all cells and compute row heights
struct LayoutRow {
std::vector<TableCell> cells;
uint16_t height = 0;
bool isHeaderRow = false;
};
std::vector<LayoutRow> layoutRows;
layoutRows.reserve(table.rows.size());
for (auto& bufRow : table.rows) {
LayoutRow lr;
lr.isHeaderRow = bufRow.isHeaderRow;
lr.cells.reserve(bufRow.cells.size());
uint16_t maxLines = 0;
for (auto& bufCell : bufRow.cells) {
TableCell cell;
cell.isHeader = bufCell.isHeader;
if (bufCell.text && !bufCell.text->isEmpty()) {
// Wrap cell text to inner column width, collecting resulting TextBlock lines
bufCell.text->layoutAndExtractLines(renderer, fontId, innerColWidth,
[&cell](const std::shared_ptr<TextBlock>& tb, bool, bool) {
if (cell.lines.size() < MAX_CELL_LINES) {
cell.lines.push_back(tb);
}
return ParsedText::LineProcessResult::Accepted;
});
}
if (cell.lines.size() > maxLines) {
maxLines = static_cast<uint16_t>(cell.lines.size());
}
lr.cells.push_back(std::move(cell));
}
// Pad rows that have fewer cells than columnCount with empty cells
while (lr.cells.size() < columnCount) {
lr.cells.emplace_back();
}
lr.height = static_cast<uint16_t>(maxLines * lineHeight + 2 * TABLE_CELL_PADDING);
if (lr.height == 0) lr.height = static_cast<uint16_t>(lineHeight + 2 * TABLE_CELL_PADDING);
layoutRows.push_back(std::move(lr));
}
// Ensure page is initialised
if (!currentPage) {
currentPage.reset(new Page());
currentPageNextY = 0;
}
// Greedily pack rows into fragments, page-breaking between fragments
std::vector<TableRow> fragmentRows;
uint16_t fragmentHeight = 0;
auto emitFragment = [&]() {
if (fragmentRows.empty()) return;
// Total height = sum of row heights + top border (1px) + bottom border included in outer rect
const uint16_t fragTotalHeight = static_cast<uint16_t>(fragmentHeight + 1); // +1 for top border pixel
// If this fragment won't fit on the current page, page-break first
if (currentPageNextY + fragTotalHeight > viewportHeight && currentPageNextY > 0) {
emitPage(lastBodyChildByteOffset);
}
auto fragment = std::make_shared<PageTableFragment>(columnCount, totalWidth, fragTotalHeight, colWidths,
std::move(fragmentRows),
/*xPos=*/0, /*yPos=*/static_cast<int16_t>(currentPageNextY));
currentPage->elements.push_back(fragment);
currentPageNextY += fragTotalHeight;
fragmentRows.clear();
fragmentHeight = 0;
};
for (auto& lr : layoutRows) {
// If a single row is taller than the full viewport, fall back for this row
if (lr.height > viewportHeight) {
// Emit whatever we have so far
emitFragment();
// Emit this oversized row as a paragraph fallback
BufferedTable singleRowFallback;
BufferedTableRow fbRow;
fbRow.isHeaderRow = lr.isHeaderRow;
for (auto& cell : lr.cells) {
BufferedTableCell fbc;
fbc.isHeader = cell.isHeader;
// Re-create a minimal ParsedText from the already-wrapped lines
// by emitting each line's words as a new ParsedText paragraph
fbc.text = std::unique_ptr<ParsedText>(new ParsedText(false, false));
for (const auto& line : cell.lines) {
for (const auto& word : line->getWords()) {
fbc.text->addWord(word, EpdFontFamily::REGULAR, false, false);
}
}
fbRow.cells.push_back(std::move(fbc));
}
singleRowFallback.rows.push_back(std::move(fbRow));
emitTableAsParagraphs(singleRowFallback);
continue;
}
const uint16_t rowContrib = static_cast<uint16_t>(lr.height + 1); // +1 for separator line
if (!fragmentRows.empty() && currentPageNextY + fragmentHeight + rowContrib > viewportHeight) {
emitFragment();
}
TableRow tr;
tr.isHeaderRow = lr.isHeaderRow;
tr.height = lr.height;
tr.cells = std::move(lr.cells);
fragmentRows.push_back(std::move(tr));
fragmentHeight += rowContrib;
}
emitFragment();
}
void ChapterHtmlSlimParser::emitTableAsParagraphs(BufferedTable& table) {
// Emit each cell as a sequential paragraph (content-preserving fallback)
for (auto& row : table.rows) {
for (auto& cell : row.cells) {
if (!cell.text || cell.text->isEmpty()) continue;
auto cellBlockStyle = BlockStyle();
cellBlockStyle.textAlignDefined = true;
cellBlockStyle.alignment = (paragraphAlignment == static_cast<uint8_t>(CssTextAlign::None))
? CssTextAlign::Justify
: static_cast<CssTextAlign>(paragraphAlignment);
// Re-use the existing paragraph pipeline by moving the cell text into currentTextBlock
startNewTextBlock(cellBlockStyle);
// Transfer words from the buffered cell text into the new currentTextBlock
// by re-running layout directly
cell.text->layoutAndExtractLines(
renderer, fontId, viewportWidth,
[this](const std::shared_ptr<TextBlock>& tb, bool lineEndsWithHyphen, bool suppressRetry) {
return addLineToPage(tb, lineEndsWithHyphen, suppressRetry);
});
}
}
}
+19 -3
View File
@@ -73,9 +73,22 @@ class ChapterHtmlSlimParser final : public Print {
bool effectiveItalic = false;
bool effectiveUnderline = false;
bool effectiveStrikethrough = false;
int tableDepth = 0;
int tableRowIndex = 0;
int tableColIndex = 0;
// Buffered table model — populated while inside <table>, emitted on </table>
struct BufferedTableCell {
std::unique_ptr<ParsedText> text;
bool isHeader = false;
};
struct BufferedTableRow {
std::vector<BufferedTableCell> cells;
bool isHeaderRow = false; // true when all cells in this row are <th>
};
struct BufferedTable {
std::vector<BufferedTableRow> rows;
int depth = 0; // nesting depth; > 1 means we're inside a nested table
bool unsupported = false; // true → emit as paragraphs instead of grid
};
std::unique_ptr<BufferedTable> currentTable;
BufferedTableCell* currentTableCell = nullptr; // non-null while inside <td>/<th>
struct ListEntry {
int depth;
@@ -140,6 +153,9 @@ class ChapterHtmlSlimParser final : public Print {
void startNewTextBlock(const BlockStyle& blockStyle);
void flushPartWordBuffer();
void makePages();
void emitBufferedTable();
void emitTableAsFragments(BufferedTable& table);
void emitTableAsParagraphs(BufferedTable& table);
// Emit currentPage to the consumer while keeping paragraphLutPerPage and completedPageCount
// in lockstep. Every page break MUST go through this helper; open-coded completePageFn
// calls risk desynchronising paragraphLutPerPage and failing the size check in Section.cpp.
+55
View File
@@ -234,6 +234,47 @@ std::vector<Span> parseInline(const std::string& text) {
return spans;
}
// Split a pipe-table row into trimmed cell strings.
// Input: "| foo | **bar** |" → ["foo", "**bar**"]
static std::vector<std::string> splitTableCells(const std::string& line) {
std::vector<std::string> cells;
size_t i = 0;
// Skip optional leading pipe
if (i < line.size() && line[i] == '|') i++;
while (i < line.size()) {
size_t start = i;
// Find next unescaped pipe
while (i < line.size() && !(line[i] == '|' && (i == 0 || line[i - 1] != '\\'))) i++;
std::string cell = line.substr(start, i - start);
// Trim whitespace
size_t cs = 0, ce = cell.size();
while (cs < ce && (cell[cs] == ' ' || cell[cs] == '\t')) cs++;
while (ce > cs && (cell[ce - 1] == ' ' || cell[ce - 1] == '\t')) ce--;
cells.push_back(cell.substr(cs, ce - cs));
if (i < line.size()) i++; // skip the pipe
}
// Drop trailing empty cell produced by trailing pipe
if (!cells.empty() && cells.back().empty()) cells.pop_back();
return cells;
}
bool isTableRow(const std::string& line) {
const std::string& t = line;
size_t i = 0;
while (i < t.size() && (t[i] == ' ' || t[i] == '\t')) i++;
return i < t.size() && t[i] == '|';
}
bool isTableSeparator(const std::string& line) {
if (!isTableRow(line)) return false;
// Every non-pipe, non-space character must be - or :
for (char c : line) {
if (c != '|' && c != '-' && c != ':' && c != ' ' && c != '\t') return false;
}
// Must contain at least one -
return line.find('-') != std::string::npos;
}
bool isCodeFence(const std::string& line) {
auto trimmed = trimLeft(line);
if (trimmed.size() < 3) return false;
@@ -382,6 +423,20 @@ ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock) {
return result;
}
// GFM pipe table
if (isTableRow(trimmed)) {
if (isTableSeparator(trimmed)) {
result.blockType = BlockType::TableSeparator;
return result;
}
result.blockType = BlockType::TableRow; // promoted to TableHeader by caller if needed
auto cells = splitTableCells(trimmed);
for (auto& cell : cells) {
result.tableCells.push_back(parseInline(cell));
}
return result;
}
// Default: paragraph
result.blockType = BlockType::Paragraph;
result.spans = parseInline(trimmed);
+11 -1
View File
@@ -23,7 +23,10 @@ enum class BlockType : uint8_t {
Blockquote,
CodeBlock,
HorizontalRule,
BlankLine
BlankLine,
TableHeader, // | col | col | — header row
TableSeparator, // | --- | --- | — ignored visually
TableRow, // | val | val | — data row
};
struct ParsedLine {
@@ -31,8 +34,15 @@ struct ParsedLine {
std::vector<Span> spans;
std::string listPrefix; // "• " or "1. " etc.
uint8_t indentLevel = 0; // Nesting depth (each 4 spaces = 1 level)
// For TableHeader / TableRow: one entry per cell (already inline-parsed)
std::vector<std::vector<Span>> tableCells;
};
// Returns true if the line is a GFM pipe-table row (| ... |) or separator (| --- |).
bool isTableRow(const std::string& line);
// Returns true if the line is a GFM table separator row (| :---: | --- |).
bool isTableSeparator(const std::string& line);
// Parse a single raw line of markdown into block type and styled spans.
// |inCodeBlock| indicates whether the line is inside a fenced code block.
ParsedLine parseLine(const std::string& rawLine, bool inCodeBlock);
+63 -1
View File
@@ -24,7 +24,7 @@ namespace {
constexpr size_t CHUNK_SIZE = 8 * 1024;
constexpr size_t MAX_LINE_LENGTH = 64 * 1024;
constexpr uint32_t CACHE_MAGIC = 0x4D4B4449; // "MKDI"
constexpr uint8_t CACHE_VERSION = 3; // Bumped: nested list indent + task checkboxes
constexpr uint8_t CACHE_VERSION = 4; // Bumped: GFM pipe table support
static std::string flattenHeadingText(const MdParser::ParsedLine& parsed) {
std::string result;
@@ -535,6 +535,68 @@ bool MdReaderActivity::loadPageAtOffset(size_t offset, bool startInCodeBlock, st
parsed = MdParser::parseLine(rawLine, inCodeBlock);
}
// GFM table handling: separator rows are invisible; the first data row in a table
// block is promoted to TableHeader (bold cells + separator line drawn after it).
if (parsed.blockType == MdParser::BlockType::TableSeparator) {
pos = lineEnd + 1;
continue;
}
if (parsed.blockType == MdParser::BlockType::TableRow) {
// Promote first row of a table to header if immediately followed by a separator.
// We detect this by peeking ahead at the next line in the buffer.
bool isHeader = false;
{
size_t nextLineStart = lineEnd + 1;
size_t nextLineEnd = nextLineStart;
while (nextLineEnd < bufferSize && pageBuffer[nextLineEnd] != '\n') nextLineEnd++;
std::string nextRaw(reinterpret_cast<char*>(pageBuffer.data() + nextLineStart), nextLineEnd - nextLineStart);
if (!nextRaw.empty() && nextRaw.back() == '\r') nextRaw.pop_back();
isHeader = MdParser::isTableSeparator(nextRaw);
}
if (isHeader) parsed.blockType = MdParser::BlockType::TableHeader;
// Flatten cells into a single line: "Cell1 │ Cell2 │ Cell3"
// Bold all spans when this is a header row.
MdParser::ParsedLine flatLine;
flatLine.blockType = MdParser::BlockType::Paragraph;
bool first = true;
for (auto& cell : parsed.tableCells) {
if (!first) {
flatLine.spans.push_back({" \xe2\x94\x82 ", EpdFontFamily::REGULAR}); // " │ "
}
first = false;
for (auto& span : cell) {
if (isHeader) {
span.style = static_cast<EpdFontFamily::Style>(static_cast<uint8_t>(span.style) |
static_cast<uint8_t>(EpdFontFamily::BOLD));
}
flatLine.spans.push_back(span);
}
}
size_t linesBefore = outLines.size();
int remainingLines = linesPerPage - static_cast<int>(outLines.size());
bool fullyConsumed = wordWrapParsedLine(flatLine, 0, outLines, remainingLines);
if (!fullyConsumed) {
if (linesBefore > 0) {
outLines.resize(linesBefore);
} else {
pos = lineComplete ? lineEnd + 1 : lineEnd;
}
break;
}
// After a header row, add a thin separator line (reuse isHR rendering)
if (isHeader && static_cast<int>(outLines.size()) < linesPerPage) {
RenderedLine sep;
sep.isHR = true;
outLines.push_back(sep);
}
pos = lineEnd + 1;
continue;
}
// Determine indent (base + nesting level)
int indent = 0;
switch (parsed.blockType) {