From d69d1dc6c680ac47d3167b1785f40fd9791a81dc Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Fri, 20 Feb 2026 15:06:07 +0100 Subject: [PATCH 1/5] refactor(paragraph): isVerticalDrop(first, second) --- src/node/elements/paragraph.js | 42 ++++++++++++++++++++------------- src/node/modules/positioning.js | 34 +++++++------------------- 2 files changed, 34 insertions(+), 42 deletions(-) diff --git a/src/node/elements/paragraph.js b/src/node/elements/paragraph.js index 73cf407..f3fdc09 100644 --- a/src/node/elements/paragraph.js +++ b/src/node/elements/paragraph.js @@ -107,33 +107,43 @@ export default class Paragraph { // * that fit into the same row: const groupedPartiallyLinedChildren = partiallyLinedChildren.reduce( (result, currentElement, currentIndex, array) => { - if (!result) { - result = [] + + // * If this is the very beginning, we start a new line: + if (!result.length) { + result = [[currentElement]]; + this._debug._ && console.log('%c➡️ ◼️ start the first line:', 'font-weight: bold; color: yellow; background-color: #808080;', currentElement); + return result; } + const currentLine = result.at(-1); + // * If BR is encountered, we start a new empty line: if(this._DOM.getElementTagName(currentElement) === 'BR' ) { - if (!result.length) result.push([]); - result.at(-1).push(currentElement); - result.push([]); // => will be: result.at(-1).length === 0; - this._debug._ && console.log('br; push:', currentElement); + currentLine.push(currentElement); + result.push([]); // => will be: currentLine.length === 0; + this._debug._ && console.log('↩️ (BR) add to line last element:', currentElement); return result; } - // * If this is the beginning, or if a new line: - if(!result.length || this._node.isLineChanged(result.at(-1).at(-1), currentElement)) { + // * If the last element was BR, we end current line and start a new one: + if(currentLine.length === 0) { + this._debug._ && console.log('⬆️ add to line 1st element:', currentElement); + currentLine.push(currentElement); + return result; + } + + const isVerticalDrop = this._node.isVerticalDrop(currentLine.at(-1), currentElement); + + // * If this is a new line: + if(isVerticalDrop) { result.push([currentElement]); - this._debug._ && console.log('◼️ start new line:', currentElement); + this._debug._ && console.log('%c➡️ ◼️ start new line with current:', 'font-weight: bold; color: yellow; background-color: #808080;', currentElement); return result; } - // TODO: isLineChanged vs isLineKept: можно сделать else? они противоположны - if( - result.at(-1).length === 0 // the last element was BR - || (result.length && this._node.isLineKept(result.at(-1).at(-1), currentElement)) - ) { - this._debug._ && console.log('⬆ add to line:', currentElement); - result.at(-1).push(currentElement); + if((!isVerticalDrop)) { + this._debug._ && console.log('⬆️ add to line:', currentElement); + currentLine.push(currentElement); return result; } diff --git a/src/node/modules/positioning.js b/src/node/modules/positioning.js index 18ab51d..98c8313 100644 --- a/src/node/modules/positioning.js +++ b/src/node/modules/positioning.js @@ -78,35 +78,17 @@ export function isLastChildOfLastChild(element, rootElement) { /** * @this {Node} */ -export function isLineChanged(current, next) { - // * (-1): Browser rounding fix (when converting mm to pixels). - const delta = this._DOM.getElementOffsetTop(next) - - this._DOM.getElementOffsetBottom(current); +export function isVerticalDrop(first, second) { + // * (-1): Browser subpixel rounding fix. + const firstBottom = this._DOM.getElementOffsetBottom(first); + const secondTop = this._DOM.getElementOffsetTop(second); + const delta = secondTop - firstBottom; const vert = delta > (-2); - // const gor = this.getElementLeft(current) + this.getElementWidth(current) > this.getElementLeft(next); - return vert; -} - -// TODO: isLineChanged vs isLineKept: можно сделать else? они противоположны -/** - * @this {Node} - */ -export function isLineKept(current, next) { - // * (-1): Browser rounding fix (when converting mm to pixels). - const currentBottom = this._DOM.getElementOffsetBottom(current); - const nextTop = this._DOM.getElementOffsetTop(next); - const delta = currentBottom - nextTop; - const vert = delta >= 2; - _isDebug(this) && console.group('isLineKept?') - _isDebug(this) && console.log( - '\n', - vert, - '\n', - '\n currentBottom', currentBottom, [current], - '\n nextTop', nextTop, [next], + _isDebug(this) && console.log('%c isVerticalDrop?', "font-weight:bold", vert, '\n delta', delta, + '\n firstBottom', firstBottom, [first], + '\n secondTop', secondTop, [second], ); - _isDebug(this) && console.groupEnd('isLineKept?') return vert; } From 7438e99e167eb39c72f467862d2aa519fcfc272c Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Fri, 20 Feb 2026 16:28:43 +0100 Subject: [PATCH 2/5] fix(style): remove "inline-block" textLine hack (for Firefox) Removed a legacy `inline-block` workaround for service text-line elements and kept a single `inline` display path. Why the workaround was added: - It was introduced to reduce Firefox issues with inline `offsetTop` measurements and baseline gap noise during line-splitting. Why we remove it now: - Firefox no longer shows the issue this workaround targeted. - The workaround caused a mismatch between measurement-time layout and final render layout. - In edge cases, text looked like it fit during calculation, but wrapped after grouping. - This was more visible with custom fonts and mixed inline content. --- src/style.js | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/style.js b/src/style.js index e99a0bd..f0b7a7c 100644 --- a/src/style.js +++ b/src/style.js @@ -302,17 +302,6 @@ ${SELECTOR.cleanBottomCut} { _serviceElementsStyle() { - - // * - Firefox and inconsistent values of offset top for inline element - // * - Visually, the string fits, but the inline baseline gap below the string - // * causes a compensator + assertions. - // * 'display: inline-block' removes spaces between parts of the string, - const _makeInlineBlock = 'display: inline-block'; - // * but it should leave the text inline in media print, - // * and inside text group. - const _keepInline = 'display: inline'; - - const screen = ` .null { display: inline; @@ -347,20 +336,12 @@ ${SELECTOR.textGroup} { display: block; } -${SELECTOR.textLine} { - ${_makeInlineBlock}; -} - -${SELECTOR.textGroup} ${SELECTOR.textLine} { - ${_keepInline}; -} - ${SELECTOR.complexTextBlock} { display: block; } ${SELECTOR.complexTextBlock} ${SELECTOR.complexTextBlock} { - ${_keepInline}; + display: inline; } ${SELECTOR.printPageBreak} { @@ -382,9 +363,6 @@ ${SELECTOR.printForcedPageBreak} { break-after: page; } - ${SELECTOR.textLine} { - ${_keepInline}; - } } `; From 25f6fed3402a68aeef81fe51b77d7917aca4dd4b Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Fri, 20 Feb 2026 17:04:51 +0100 Subject: [PATCH 3/5] refactor(paragraph): always wrap split lines in textGroup for consistent line measurement Wrap every split line in `textGroup` to stabilize measurements: each line gets a block-level wrapper, while inline flow is preserved inside the group, keeping the original visual appearance. --- src/node/elements/paragraph.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/node/elements/paragraph.js b/src/node/elements/paragraph.js index f3fdc09..1768bd0 100644 --- a/src/node/elements/paragraph.js +++ b/src/node/elements/paragraph.js @@ -205,8 +205,11 @@ export default class Paragraph { newLine = arr[0]; newLine.setAttribute('role', '🚫'); this.strictAssert(arr.length == 0, 'The string cannot be empty (_splitComplexTextBlockIntoLines)') - } else if (arr.length == 1) { - newLine = arr[0]; + // } else if (arr.length == 1) { + // newLine = arr[0]; + // * Wrap every split line in textGroup to stabilize measurements: + // * each line gets a block-level wrapper, while inline flow is preserved inside the group, + // * keeping the original visual appearance.` } else { const group = this._node.createTextGroup(); newLine = group; From d187f179c2c294cc567dec13544f12d302747f08 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Fri, 20 Feb 2026 18:30:07 +0100 Subject: [PATCH 4/5] tests(paragraph): consider the group wrapping of lines --- .../2000_splitters/2033_complex_text_block/test_case.py | 2 +- .../2034_standalone_inline_wrapper/test_case.py | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/test/end2end/2000_splitters/2033_complex_text_block/test_case.py b/test/end2end/2000_splitters/2033_complex_text_block/test_case.py index d3b0cb2..20450be 100644 --- a/test/end2end/2000_splitters/2033_complex_text_block/test_case.py +++ b/test/end2end/2000_splitters/2033_complex_text_block/test_case.py @@ -28,7 +28,7 @@ # 6 lines are divided into 4 groups (2 lines first and last form a group) lines_1_2 = '//html2pdf4doc-text-group[@data-child="0"]' lines_3 = '//html2pdf4doc-text-group[@data-child="1"]' -lines_4 = '//span[@data-child="2"]' +lines_4 = '//html2pdf4doc-text-group[@data-child="2"]' lines_5_6 = '//html2pdf4doc-text-group[@data-child="3"]' diff --git a/test/end2end/2000_splitters/2034_standalone_inline_wrapper/test_case.py b/test/end2end/2000_splitters/2034_standalone_inline_wrapper/test_case.py index 4fcbfdb..f5d7726 100644 --- a/test/end2end/2000_splitters/2034_standalone_inline_wrapper/test_case.py +++ b/test/end2end/2000_splitters/2034_standalone_inline_wrapper/test_case.py @@ -20,13 +20,16 @@ # On Windows / MacOS / Linux, different fonts and text are split differently. # Therefore, here we only check the case with a standalone inline wrapper ``. -# We are checking the structure here when `inline_parent` is included in `complex-text-block`, +# We are checking the structure here when `inline_parent`(wrapped in group_wrapper), +# is included in `complex-text-block`, # and in turn contains text in service wrappers `text-node` and `text-line`. parent = '/div[@data-testid="test-block"]' ctb = '/html2pdf4doc-complex-text-block' -inline_parent_part = '/tt[@data-child]' +# and each line is wrapped into a group +group_wrapper = '/html2pdf4doc-text-group[@data-child]' +inline_parent_part = '/tt' inner_service_blocks = '/html2pdf4doc-text-node/html2pdf4doc-text-line' -tester = parent + ctb + inline_parent_part + inner_service_blocks +tester = parent + ctb + group_wrapper + inline_parent_part + inner_service_blocks class Test(BaseCase): def __init__(self, *args, **kwargs): From bd7d993f8e6f5d78e8beb22e11acee66bd412ae7 Mon Sep 17 00:00:00 2001 From: Maryna Balioura Date: Fri, 20 Feb 2026 20:57:33 +0100 Subject: [PATCH 5/5] fix(paragraph): use cached DOMRect metrics for line-start detection (because FF cannot take offset from inline) --- src/node/elements/paragraph.js | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/node/elements/paragraph.js b/src/node/elements/paragraph.js index 1768bd0..2aaaac5 100644 --- a/src/node/elements/paragraph.js +++ b/src/node/elements/paragraph.js @@ -381,15 +381,34 @@ export default class Paragraph { const cashInlineLineHeight = wrapper.style.lineHeight; wrapper.style.lineHeight = 2; + // Cache geometry for this single measurement pass. + // We read layout from the browser only once per element and reuse it in comparisons/logs. + const rectCache = new WeakMap(); + const getRectCached = (element) => { + if (!element) return null; + const cached = rectCache.get(element); + if (cached) return cached; + const rect = this._DOM.getElementBCR(element); + rectCache.set(element, rect); + return rect; + }; + + // Line start detection in Firefox is unreliable via offsetTop for inline fragments. + // Compute it from DOMRect: + const getTopCached = (element) => getRectCached(element)?.top; + const getBottomCached = (element) => getRectCached(element)?.bottom; + // Split the splittedItem into lines. // Let's find the elements that start a new line. const newLineStartNumbers = wrappedWordsArray.reduce( (result, currentWord, currentIndex) => { - const prevTop = (currentIndex > 0) ? wrappedWordsArray[currentIndex - 1].offsetTop : undefined; - const prevHth = (currentIndex > 0) ? wrappedWordsArray[currentIndex - 1].offsetHeight : undefined; - const currTop = currentWord.offsetTop; - if (currentIndex > 0 && (prevTop + prevHth) <= currTop) { + const prevWord = currentIndex > 0 ? wrappedWordsArray[currentIndex - 1] : null; + // * prevBottom <= currTop means the next token starts a new line. + const prevBottom = currentIndex > 0 ? getBottomCached(prevWord) : undefined; + const currTop = getTopCached(currentWord); + const isNewLine = (currentIndex > 0) ? (prevBottom <= currTop) : false; + if (isNewLine) { result.push(currentIndex); } return result;