diff --git a/src/DocumentContext.js b/src/DocumentContext.js index 94e5d3bee..b8619c029 100644 --- a/src/DocumentContext.js +++ b/src/DocumentContext.js @@ -23,13 +23,14 @@ class DocumentContext extends EventEmitter { this.addPage(pageSize); } - beginColumnGroup(marginXTopParent) { + beginColumnGroup(marginXTopParent, bottomByPage = {}) { this.snapshots.push({ x: this.x, y: this.y, availableHeight: this.availableHeight, availableWidth: this.availableWidth, page: this.page, + bottomByPage: bottomByPage ? bottomByPage : {}, bottomMost: { x: this.x, y: this.y, @@ -46,6 +47,16 @@ class DocumentContext extends EventEmitter { } } + updateBottomByPage () { + const lastSnapshot = this.snapshots[this.snapshots.length - 1]; + const lastPage = this.page; + let previousBottom = -Number.MIN_VALUE; + if (lastSnapshot.bottomByPage[lastPage]) { + previousBottom = lastSnapshot.bottomByPage[lastPage]; + } + lastSnapshot.bottomByPage[lastPage] = Math.max(previousBottom, this.y); + } + resetMarginXTopParent() { this.marginXTopParent = null; } @@ -118,6 +129,7 @@ class DocumentContext extends EventEmitter { this.availableHeight -= (y - saved.bottomMost.y); } this.lastColumnWidth = saved.lastColumnWidth; + return saved.bottomByPage; } addMargin(left, right) { diff --git a/src/LayoutBuilder.js b/src/LayoutBuilder.js index a06592559..258ccf2e3 100644 --- a/src/LayoutBuilder.js +++ b/src/LayoutBuilder.js @@ -539,7 +539,15 @@ class LayoutBuilder { } } - findStartingSpanCell(arr, i) { + /** + * Searches for a cell in the same row that starts a rowspan and is positioned immediately before the current cell. + * Alternatively, it finds a cell where the colspan initiating the rowspan extends to the cell just before the current one. + * + * @param {Array} arr - An array representing cells in a row. + * @param {number} i - The index of the current cell to search backward from. + * @returns {object|null} The starting cell of the rowspan if found; otherwise, `null`. + */ + _findStartingRowSpanCell(arr, i) { let requiredColspan = 1; for (let index = i - 1; index >= 0; index--) { if (!arr[index]._span) { @@ -554,114 +562,239 @@ class LayoutBuilder { return null; } - processRow({ marginX = [0, 0], dontBreakRows = false, rowsWithoutPageBreak = 0, cells, widths, gaps, tableBody, rowIndex, height }) { - const updatePageBreakData = (page, prevY) => { - let pageDesc; - // Find page break data for this row and page - for (let i = 0, l = pageBreaks.length; i < l; i++) { - let desc = pageBreaks[i]; - if (desc.prevPage === page) { - pageDesc = desc; - break; - } - } - // If row has page break in this page, update prevY - if (pageDesc) { - pageDesc.prevY = Math.max(pageDesc.prevY, prevY); - } + /** + * Retrieves a page break description for a specified page from a list of page breaks. + * + * @param {Array} pageBreaks - An array of page break descriptions, each containing `prevPage` properties. + * @param {number} page - The page number to find the associated page break for. + * @returns {object|undefined} The page break description object for the specified page if found; otherwise, `undefined`. + */ + _getPageBreak(pageBreaks, page) { + return pageBreaks.find(desc => desc.prevPage === page); + } + + _getPageBreakListBySpan(tableNode, page, rowIndex) { + if (!tableNode || !tableNode._breaksBySpan) { + return null; + } + const breaksList = tableNode._breaksBySpan.filter(desc => desc.prevPage === page && rowIndex <= desc.rowIndexOfSpanEnd); + + let y = Number.MAX_VALUE, + prevY = Number.MIN_VALUE; + + breaksList.forEach(b => { + prevY = Math.max(b.prevY, prevY); + y = Math.min(b.y, y); + }); + + return { + prevPage: page, + prevY: prevY, + y: y }; + } + + _findSameRowPageBreakByRowSpanData(breaksBySpan, page, rowIndex) { + if (!breaksBySpan) { + return null; + } + return breaksBySpan.find(desc => desc.prevPage === page && rowIndex === desc.rowIndexOfSpanEnd); + } - const storePageBreakData = data => { - let pageDesc; + _updatePageBreaksData(pageBreaks, tableNode, rowIndex) { + Object.keys(tableNode._bottomByPage).forEach(p => { + const page = Number(p); + const pageBreak = this._getPageBreak(pageBreaks, page); + if (pageBreak) { + pageBreak.prevY = Math.max(pageBreak.prevY, tableNode._bottomByPage[page]); + } + if (tableNode._breaksBySpan && tableNode._breaksBySpan.length > 0) { + const breaksBySpanList = tableNode._breaksBySpan.filter(pb => pb.prevPage === page && rowIndex <= pb.rowIndexOfSpanEnd); - for (let i = 0, l = pageBreaks.length; i < l; i++) { - let desc = pageBreaks[i]; - if (desc.prevPage === data.prevPage) { - pageDesc = desc; - break; + if (breaksBySpanList && breaksBySpanList.length > 0) { + breaksBySpanList.forEach(b => { + b.prevY = Math.max(b.prevY, tableNode._bottomByPage[page]); + }); } } + }); + } + /** + * Resolves the Y-coordinates for a target object by comparing two break points. + * + * @param {object} break1 - The first break point with `prevY` and `y` properties. + * @param {object} break2 - The second break point with `prevY` and `y` properties. + * @param {object} target - The target object to be updated with resolved Y-coordinates. + * @property {number} target.prevY - Updated to the maximum `prevY` value between `break1` and `break2`. + * @property {number} target.y - Updated to the minimum `y` value between `break1` and `break2`. + */ + _resolveBreakY (break1, break2, target) { + target.prevY = Math.max(break1.prevY, break2.prevY); + target.y = Math.min(break1.y, break2.y); + }; + + _storePageBreakData (data, startsRowSpan, pageBreaks, tableNode) { + if (!startsRowSpan) { + let pageDesc = this._getPageBreak(pageBreaks, data.prevPage); + let pageDescBySpan = this._getPageBreakListBySpan(tableNode, data.prevPage, data.rowIndex); if (!pageDesc) { - pageDesc = data; + pageDesc = { + ...data + }; pageBreaks.push(pageDesc); } - pageDesc.prevY = Math.max(pageDesc.prevY, data.prevY); - pageDesc.y = Math.min(pageDesc.y, data.y); - }; + if (pageDescBySpan) { + this._resolveBreakY(pageDesc, pageDescBySpan, pageDesc); + } + this._resolveBreakY(pageDesc, data, pageDesc); + } else { + const breaksBySpan = tableNode && tableNode._breaksBySpan || null; + let pageDescBySpan = this._findSameRowPageBreakByRowSpanData(breaksBySpan, data.prevPage, data.rowIndex); + if (!pageDescBySpan) { + pageDescBySpan = { + ...data, + rowIndexOfSpanEnd: data.rowIndex + data.rowSpan - 1 + }; + if (!tableNode._breaksBySpan) { + tableNode._breaksBySpan = []; + } + tableNode._breaksBySpan.push(pageDescBySpan); + } + pageDescBySpan.prevY = Math.max(pageDescBySpan.prevY, data.prevY); + pageDescBySpan.y = Math.min(pageDescBySpan.y, data.y); + let pageDesc = this._getPageBreak(pageBreaks, data.prevPage); + if (pageDesc) { + this._resolveBreakY(pageDesc, pageDescBySpan, pageDesc); + } + } + }; + /** + * Calculates the left offset for a column based on the specified gap values. + * + * @param {number} i - The index of the column for which the offset is being calculated. + * @param {Array} gaps - An array of gap values for each column. + * @returns {number} The left offset for the column. Returns `gaps[i]` if it exists, otherwise `0`. + */ + _colLeftOffset(i, gaps) { + if (gaps && gaps.length > i) { + return gaps[i]; + } + return 0; + } + + /** + * Retrieves the ending cell for a row span in case it exists in a specified table column. + * + * @param {Array>} tableBody - The table body, represented as a 2D array of cell objects. + * @param {number} rowIndex - The index of the starting row for the row span. + * @param {object} column - The column object containing row span information. + * @param {number} columnIndex - The index of the column within the row. + * @returns {object|null} The cell at the end of the row span if it exists; otherwise, `null`. + * @throws {Error} If the row span extends beyond the total row count. + */ + _getRowSpanEndingCell(tableBody, rowIndex, column, columnIndex) { + if (column.rowSpan && column.rowSpan > 1) { + let endingRow = rowIndex + column.rowSpan - 1; + if (endingRow >= tableBody.length) { + throw new Error(`Row span for column ${columnIndex} (with indexes starting from 0) exceeded row count`); + } + return tableBody[endingRow][columnIndex]; + } + + return null; + } + + processRow({ marginX = [0, 0], dontBreakRows = false, rowsWithoutPageBreak = 0, cells, widths, gaps, tableNode, tableBody, rowIndex, height }) { const isUnbreakableRow = dontBreakRows || rowIndex <= rowsWithoutPageBreak - 1; let pageBreaks = []; + let pageBreaksByRowSpan = []; let positions = []; let willBreakByHeight = false; - this.writer.addListener('pageChanged', storePageBreakData); + widths = widths || cells; // Check if row should break by height if (!isUnbreakableRow && height > this.writer.context().availableHeight) { willBreakByHeight = true; } - widths = widths || cells; // Use the marginX if we are in a top level table/column (not nested) const marginXParent = this.nestedLevel === 1 ? marginX : null; - - this.writer.context().beginColumnGroup(marginXParent); + const _bottomByPage = tableNode ? tableNode._bottomByPage : null; + this.writer.context().beginColumnGroup(marginXParent, _bottomByPage); for (let i = 0, l = cells.length; i < l; i++) { - let column = cells[i]; + let cell = cells[i]; + + // Page change handler + const storePageBreakClosure = data => { + const startsRowSpan = cell.rowSpan && cell.rowSpan > 1; + if (startsRowSpan) { + data.rowSpan = cell.rowSpan; + } + data.rowIndex = rowIndex; + this._storePageBreakData(data, startsRowSpan, pageBreaks, tableNode); + }; + + this.writer.addListener('pageChanged', storePageBreakClosure); + let width = widths[i]._calcWidth; - let leftOffset = colLeftOffset(i); + let leftOffset = this._colLeftOffset(i, gaps); + // Check if exists and retrieve the cell that started the rowspan in case we are in the cell just after + let startingSpanCell = this._findStartingRowSpanCell(cells, i); - if (column.colSpan && column.colSpan > 1) { - for (let j = 1; j < column.colSpan; j++) { + if (cell.colSpan && cell.colSpan > 1) { + for (let j = 1; j < cell.colSpan; j++) { width += widths[++i]._calcWidth + gaps[i]; } } // if rowspan starts in this cell, we retrieve the last cell affected by the rowspan - const endingCell = getEndingCell(column, i); - if (endingCell) { + const rowSpanEndingCell = this._getRowSpanEndingCell(tableBody, rowIndex, cell, i); + if (rowSpanEndingCell) { // We store a reference of the ending cell in the first cell of the rowspan - column._endingCell = endingCell; - column._endingCell._startingRowSpanY = column._startingRowSpanY; + cell._endingCell = rowSpanEndingCell; + cell._endingCell._startingRowSpanY = cell._startingRowSpanY; } - // Check if exists and retrieve the cell that started the rowspan in case we are in the cell just after - let startingSpanCell = this.findStartingSpanCell(cells, i); - let endingSpanCell = null; + // If we are after a cell that started a rowspan + let endOfRowSpanCell = null; if (startingSpanCell && startingSpanCell._endingCell) { // Reference to the last cell of the rowspan - endingSpanCell = startingSpanCell._endingCell; + endOfRowSpanCell = startingSpanCell._endingCell; // Store if we are in an unbreakable block when we save the context and the originalX if (this.writer.transactionLevel > 0) { - endingSpanCell._isUnbreakableContext = true; - endingSpanCell._originalXOffset = this.writer.originalX; + endOfRowSpanCell._isUnbreakableContext = true; + endOfRowSpanCell._originalXOffset = this.writer.originalX; } } // We pass the endingSpanCell reference to store the context just after processing rowspan cell - this.writer.context().beginColumn(width, leftOffset, endingSpanCell); + this.writer.context().beginColumn(width, leftOffset, endOfRowSpanCell); - if (!column._span) { - this.processNode(column); - addAll(positions, column.positions); - } else if (column._columnEndingContext) { + if (!cell._span) { + this.processNode(cell); + this.writer.context().updateBottomByPage(); + addAll(positions, cell.positions); + } else if (cell._columnEndingContext) { let discountY = 0; if (dontBreakRows) { // Calculate how many points we have to discount to Y when dontBreakRows and rowSpan are combined const ctxBeforeRowSpanLastRow = this.writer.contextStack[this.writer.contextStack.length - 1]; - discountY = ctxBeforeRowSpanLastRow.y - column._startingRowSpanY; + discountY = ctxBeforeRowSpanLastRow.y - cell._startingRowSpanY; } let originalXOffset = 0; // If context was saved from an unbreakable block and we are not in an unbreakable block anymore // We have to sum the originalX (X before starting unbreakable block) to X - if (column._isUnbreakableContext && !this.writer.transactionLevel) { - originalXOffset = column._originalXOffset; + if (cell._isUnbreakableContext && !this.writer.transactionLevel) { + originalXOffset = cell._originalXOffset; } // row-span ending // Recover the context after processing the rowspanned cell - this.writer.context().markEnding(column, originalXOffset, discountY); + this.writer.context().markEnding(cell, originalXOffset, discountY); } + this.writer.removeListener('pageChanged', storePageBreakClosure); } // Check if last cell is part of a span @@ -674,7 +807,7 @@ class LayoutBuilder { // Previous column cell is part of a span } else if (lastColumn._span === true) { // We get the cell that started the span where we set a reference to the ending cell - const startingSpanCell = this.findStartingSpanCell(cells, cells.length); + const startingSpanCell = this._findStartingRowSpanCell(cells, cells.length); if (startingSpanCell) { // Context will be stored here (ending cell) endingSpanCell = startingSpanCell._endingCell; @@ -687,38 +820,25 @@ class LayoutBuilder { } } - // If there are page breaks in this row, update data with prevY of last cell - updatePageBreakData(this.writer.context().page, this.writer.context().y); - // If content did not break page, check if we should break by height - if (!isUnbreakableRow && pageBreaks.length === 0 && willBreakByHeight) { + if (willBreakByHeight && !isUnbreakableRow && pageBreaks.length === 0) { this.writer.context().moveDown(this.writer.context().availableHeight); this.writer.moveToNextPage(); } - this.writer.context().completeColumnGroup(height, endingSpanCell); - this.writer.removeListener('pageChanged', storePageBreakData); + const bottomByPage = this.writer.context().completeColumnGroup(height, endingSpanCell); - return { pageBreaks: pageBreaks, positions: positions }; - - function colLeftOffset(i) { - if (gaps && gaps.length > i) { - return gaps[i]; - } - return 0; + if (tableNode) { + tableNode._bottomByPage = bottomByPage; + // If there are page breaks in this row, update data with prevY of last cell + this._updatePageBreaksData(pageBreaks, tableNode, rowIndex); } - function getEndingCell(column, columnIndex) { - if (column.rowSpan && column.rowSpan > 1) { - let endingRow = rowIndex + column.rowSpan - 1; - if (endingRow >= tableBody.length) { - throw new Error(`Row span for column ${columnIndex} (with indexes starting from 0) exceeded row count`); - } - return tableBody[endingRow][columnIndex]; - } - - return null; - } + return { + pageBreaksBySpan : pageBreaksByRowSpan, + pageBreaks: pageBreaks, + positions: positions + }; } // lists @@ -799,6 +919,8 @@ class LayoutBuilder { height = undefined; } + const pageBeforeProcessing = this.writer.context().page; + let result = this.processRow({ marginX: tableNode._margin ? [tableNode._margin[0], tableNode._margin[2]] : [0, 0], dontBreakRows: processor.dontBreakRows, @@ -807,12 +929,22 @@ class LayoutBuilder { widths: tableNode.table.widths, gaps: tableNode._offsets.offsets, tableBody: tableNode.table.body, + tableNode, rowIndex: i, height }); addAll(tableNode.positions, result.positions); + if (!result.pageBreaks || result.pageBreaks.length === 0) { + const breaksBySpan = tableNode && tableNode._breaksBySpan || null; + const breakBySpanData = this._findSameRowPageBreakByRowSpanData(breaksBySpan, pageBeforeProcessing, i); + if (breakBySpanData) { + const finalBreakBySpanData = this._getPageBreakListBySpan(tableNode, breakBySpanData.prevPage, i); + result.pageBreaks.push(finalBreakBySpanData); + } + } + processor.endRow(i, this.writer, result.pageBreaks); } diff --git a/tests/unit/LayoutBuilder.spec.js b/tests/unit/LayoutBuilder.spec.js index 8dba616a3..4a7c8abfb 100644 --- a/tests/unit/LayoutBuilder.spec.js +++ b/tests/unit/LayoutBuilder.spec.js @@ -1720,7 +1720,7 @@ describe('LayoutBuilder', function () { assert.equal(result.pageBreaks.length, 1); assert.equal(result.pageBreaks[0].prevPage, 0); - assert.equal(result.pageBreaks[0].prevY, 40 + 12 * 11); + assert.equal(result.pageBreaks[0].prevY, 40 + 12 * 5); }); it('on multi-pass page break (columns or table columns) should treat bottom-most page-break as the ending position ', function () { @@ -1755,7 +1755,7 @@ describe('LayoutBuilder', function () { }); it('on multiple page breaks (more than 2 pages), should return all entries with ending/starting positions 2', function () { - var doc = createTable(0, 1, 100, 90); + var doc = createTable(0, 1, 100, 90, 90); var result = builder2.processRow({ cells: doc.table.body[0], widths: doc.table.widths, @@ -1769,7 +1769,7 @@ describe('LayoutBuilder', function () { assert.equal(result.pageBreaks[0].prevPage, 0); assert.equal(result.pageBreaks[0].prevY, 40 + 60 * 12); assert.equal(result.pageBreaks[1].prevPage, 1); - assert.equal(result.pageBreaks[1].prevY, 40 + 41 * 12); + assert.equal(result.pageBreaks[1].prevY, 40 + 30 * 12); }); it('on multiple and multi-pass page breaks should calculate bottom-most endings for every page', function () {