diff --git a/lib/Epub/Epub/Page.cpp b/lib/Epub/Epub/Page.cpp index 2777a871..bf0e2159 100644 --- a/lib/Epub/Epub/Page.cpp +++ b/lib/Epub/Epub/Page.cpp @@ -1,5 +1,6 @@ #include "Page.h" +#include #include #include @@ -48,6 +49,135 @@ std::unique_ptr PageImage::deserialize(FsFile& file) { return std::unique_ptr(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(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(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(row.cells.size()); + serialization::writePod(file, cellCount); + for (const auto& cell : row.cells) { + serialization::writePod(file, cell.isHeader); + const uint8_t lineCount = static_cast(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::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 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 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( + 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::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; diff --git a/lib/Epub/Epub/Page.h b/lib/Epub/Epub/Page.h index 46814b15..8b91fb92 100644 --- a/lib/Epub/Epub/Page.h +++ b/lib/Epub/Epub/Page.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -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> lines; + bool isHeader = false; +}; + +struct TableRow { + std::vector 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 colWidths = {}; + std::vector rows; + + public: + PageTableFragment(uint8_t colCount, uint16_t totalWidth, uint16_t totalHeight, + std::array colWidths, std::vector 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 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 diff --git a/lib/Epub/Epub/Section.cpp b/lib/Epub/Epub/Section.cpp index 806433a8..be8fe1f2 100644 --- a/lib/Epub/Epub/Section.cpp +++ b/lib/Epub/Epub/Section.cpp @@ -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 diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp index 132a29c9..c844f54f 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.cpp @@ -188,25 +188,29 @@ void ChapterHtmlSlimParser::flushPartWordBuffer() { fontStyle = static_cast(fontStyle | EpdFontFamily::STRIKETHROUGH); } - // flush the buffer + // flush the buffer — route to table cell text when inside a / 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(viewportWidth - horizontalInset) : viewportWidth; + currentTextBlock->layoutAndExtractLines( + renderer, fontId, effectiveWidth, + [this](const std::shared_ptr& 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(viewportWidth - horizontalInset) : viewportWidth; - currentTextBlock->layoutAndExtractLines( - renderer, fontId, effectiveWidth, - [this](const std::shared_ptr& 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 . 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(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(CssTextAlign::None)) - ? CssTextAlign::Justify - : static_cast(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(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(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(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(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(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 colWidths = {}; + for (uint8_t c = 0; c < columnCount; c++) { + colWidths[c] = colWidth; + } + + const int lineHeight = static_cast(renderer.getLineHeight(fontId) * lineCompression + 0.5f); + + // Pre-wrap all cells and compute row heights + struct LayoutRow { + std::vector cells; + uint16_t height = 0; + bool isHeaderRow = false; + }; + std::vector 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& 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(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(maxLines * lineHeight + 2 * TABLE_CELL_PADDING); + if (lr.height == 0) lr.height = static_cast(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 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(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(columnCount, totalWidth, fragTotalHeight, colWidths, + std::move(fragmentRows), + /*xPos=*/0, /*yPos=*/static_cast(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(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(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(CssTextAlign::None)) + ? CssTextAlign::Justify + : static_cast(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& tb, bool lineEndsWithHyphen, bool suppressRetry) { + return addLineToPage(tb, lineEndsWithHyphen, suppressRetry); + }); + } + } +} diff --git a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h index 5e5e4250..30467157 100644 --- a/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h +++ b/lib/Epub/Epub/parsers/ChapterHtmlSlimParser.h @@ -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 , emitted on
+ struct BufferedTableCell { + std::unique_ptr text; + bool isHeader = false; + }; + struct BufferedTableRow { + std::vector cells; + bool isHeaderRow = false; // true when all cells in this row are + }; + struct BufferedTable { + std::vector 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 currentTable; + BufferedTableCell* currentTableCell = nullptr; // non-null while inside / 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. diff --git a/lib/Md/MdParser.cpp b/lib/Md/MdParser.cpp index 2dc61592..117b4aaf 100644 --- a/lib/Md/MdParser.cpp +++ b/lib/Md/MdParser.cpp @@ -234,6 +234,47 @@ std::vector parseInline(const std::string& text) { return spans; } +// Split a pipe-table row into trimmed cell strings. +// Input: "| foo | **bar** |" → ["foo", "**bar**"] +static std::vector splitTableCells(const std::string& line) { + std::vector 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); diff --git a/lib/Md/MdParser.h b/lib/Md/MdParser.h index a4ba8ce5..a5f68062 100644 --- a/lib/Md/MdParser.h +++ b/lib/Md/MdParser.h @@ -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 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> 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); diff --git a/src/activities/reader/MdReaderActivity.cpp b/src/activities/reader/MdReaderActivity.cpp index 6c211221..3aa172be 100644 --- a/src/activities/reader/MdReaderActivity.cpp +++ b/src/activities/reader/MdReaderActivity.cpp @@ -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(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(static_cast(span.style) | + static_cast(EpdFontFamily::BOLD)); + } + flatLine.spans.push_back(span); + } + } + + size_t linesBefore = outLines.size(); + int remainingLines = linesPerPage - static_cast(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(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) {