diff --git a/src/LayoutBuilder.js b/src/LayoutBuilder.js index 183ac99cf..1264e0df8 100644 --- a/src/LayoutBuilder.js +++ b/src/LayoutBuilder.js @@ -1406,6 +1406,8 @@ class LayoutBuilder { listContext: this._accessibilityListStack.length > 0 ? { depth: this._accessibilityListStack.length, itemIndex: this._accessibilityListStack[this._accessibilityListStack.length - 1].itemIndex, + listNode: this._accessibilityListStack[this._accessibilityListStack.length - 1].node, + parentItemIndices: this._accessibilityListStack.slice(0, -1).map(e => e.itemIndex), isFirstInItem: isFirstLine && this._accessibilityListStack[this._accessibilityListStack.length - 1].isFirstNodeInItem, isLastInItem: line.lastLineInParagraph && this._accessibilityListStack[this._accessibilityListStack.length - 1].isLastNodeInItem } : null, diff --git a/src/Renderer.js b/src/Renderer.js index c8293505f..c51cf69a4 100644 --- a/src/Renderer.js +++ b/src/Renderer.js @@ -195,6 +195,9 @@ class Renderer { // Accessibility: manage logical structure elements based on this line's context if (tagger && line._accessibilityContext) { + if (line.inlines) { + tagger._currentLineText = line.inlines.map(i => i.text || '').join(''); + } _manageAccessibilityStructures(tagger, taggerState, line._accessibilityContext); } @@ -664,7 +667,7 @@ function _manageAccessibilityStructures(tagger, state, ctx) { // ==================== LIST MANAGEMENT ==================== if (curLC && !prevLC) { - // Entering a list for the first time + // Entering a list for the first time (or re-entering after mid-page close) tagger.beginList(); tagger.beginListItem(); } else if (curLC && prevLC) { @@ -672,14 +675,40 @@ function _manageAccessibilityStructures(tagger, state, ctx) { // Nested list starting tagger.beginList(); tagger.beginListItem(); + } else if (curLC.depth < prevLC.depth) { + // Returning from nested list(s) — close each inner list level explicitly. + // Without this, currentList stays as the innermost L element and new items + // get added to the wrong list in the structure tree. + for (let d = prevLC.depth; d > curLC.depth; d--) { + tagger.endList(); + } + // prevLC.itemIndex is the inner list's counter and is not comparable to + // curLC.itemIndex. Use parentItemIndices to get the outer list's item index + // from when we were inside the nested list. + const prevOuterItemIndex = prevLC.parentItemIndices[curLC.depth - 1]; + if (curLC.itemIndex !== prevOuterItemIndex) { + tagger.beginListItem(); + } + } else if (curLC.listNode !== prevLC.listNode) { + // Same depth but a different list — close the previous list and start a new one. + // Without this, adjacent {ul:[...]} blocks at the same depth are silently merged + // into a single L element because !curLC && prevLC never fires between them. + tagger.endList(); + tagger.beginList(); + tagger.beginListItem(); } else if (curLC.itemIndex !== prevLC.itemIndex) { - // New item at same depth (previous item was closed by processLineEnd) + // Same list, same depth, new item (previous item was closed by processLineEnd) tagger.beginListItem(); } - // depth decrease is handled by processLineEnd + the next beginList/beginListItem + } else if (!curLC && prevLC) { + // Left all list nesting mid-page — close remaining lists now rather than + // deferring to the next page-change _closeAllOpenStructures call. + // This prevents stale L/LI/LBody references from accumulating and being + // pushed onto the listStack when a subsequent list begins on the same page. + while (tagger.getListDepth() > 0) { + tagger.endList(); + } } - // Leaving a list (prevLC && !curLC) is handled by tagger._closeAllOpenStructures on page change - // and by finalise() at document end state.prevListContext = curLC || null; diff --git a/src/accessibilityTagger.js b/src/accessibilityTagger.js index 48735652a..da7bb70bc 100644 --- a/src/accessibilityTagger.js +++ b/src/accessibilityTagger.js @@ -65,6 +65,9 @@ class AccessibilityTagger { // Current figure this._currentFigure = null; + + // Set by the renderer before each _manageAccessibilityStructures call; used in warnings + this._currentLineText = ''; } /** @@ -532,30 +535,54 @@ class AccessibilityTagger { /** * Get the current parent element for adding new child structures. - * Priority order: cell > LBody > Sect + * Priority order: cell > row (TOC) > LBody > BlockQuote > Sect > Document + * + * Skips and nulls any reference that was cascade-ended by PDFKit without + * the tagger explicitly closing it, which would otherwise cause + * "Cannot add child to already-ended structure element" errors. * * @returns {object|null} The current PDFKit struct element to use as parent */ _getCurrentParent() { if (this.currentCell) { - return this.currentCell; + if (!this.currentCell._ended) { return this.currentCell; } + console.warn(`AccessibilityTagger: currentCell was cascade-ended unexpectedly (line text: "${this._currentLineText}")`); + this.currentCell = null; } // For TOC tables, content goes directly under TOCI (the row), not a cell if (this.tableIsTOC && this.currentRow) { - return this.currentRow; + if (!this.currentRow._ended) { return this.currentRow; } + console.warn(`AccessibilityTagger: currentRow (TOC) was cascade-ended unexpectedly (line text: "${this._currentLineText}")`); + this.currentRow = null; } if (this.currentLBody) { - return this.currentLBody; + if (!this.currentLBody._ended) { return this.currentLBody; } + console.warn(`AccessibilityTagger: currentLBody was cascade-ended unexpectedly (line text: "${this._currentLineText}")`); + this.currentLBody = null; } if (this.currentBlockQuote) { - return this.currentBlockQuote; + if (!this.currentBlockQuote._ended) { return this.currentBlockQuote; } + console.warn(`AccessibilityTagger: currentBlockQuote was cascade-ended unexpectedly (line text: "${this._currentLineText}")`); + this.currentBlockQuote = null; } if (this.currentSect) { - return this.currentSect; + if (!this.currentSect._ended) { return this.currentSect; } + console.warn(`AccessibilityTagger: currentSect was cascade-ended unexpectedly (line text: "${this._currentLineText}")`); + this.currentSect = null; } return this.documentElement; } + /** + * Returns the current list nesting depth as the tagger sees it. + * Used by the renderer to close the right number of list levels. + * + * @returns {number} The number of currently open list levels. + */ + getListDepth() { + return this.listStack.length + (this.currentList ? 1 : 0); + } + /** * Close all currently open structures (for page transitions etc.) */