From bcd9ed724be51634af1b7a443830cd8a040620c9 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Thu, 18 Jun 2026 16:15:22 +0800 Subject: [PATCH] fix(reader): paginate inline-block-wrapped chapters instead of clipping them (#4641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(reader): paginate inline-block-wrapped chapters instead of clipping them (readest/foliate-js#27) Some EPUBs wrap a large chunk of chapter content in a div the stylesheet declares as `display: inline-block`. Atomic inline-level boxes can't fragment across CSS columns, so in paginated mode the tall box overflows the page vertically and every column past the first is clipped — the chapter jumps straight to its "Reference materials", silently skipping a large middle section, while the counter reads "1 page left in chapter". Bumps the foliate-js submodule with #demoteUnfragmentableBoxes (demotes over-tall atomic-inline boxes to their fragmentable block equivalents in column mode) and adds a browser test + repro EPUB fixture. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(deps): bump foliate-js submodule to merged main (#27) Re-point packages/foliate-js from the PR-branch commit to the squash-merged main SHA now that readest/foliate-js#27 has landed. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...ator-inline-block-overflow.browser.test.ts | 107 ++++++++++++++++++ .../data/repro-inline-block-overflow.epub | Bin 0 -> 2002 bytes packages/foliate-js | 2 +- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 apps/readest-app/src/__tests__/document/paginator-inline-block-overflow.browser.test.ts create mode 100644 apps/readest-app/src/__tests__/fixtures/data/repro-inline-block-overflow.epub diff --git a/apps/readest-app/src/__tests__/document/paginator-inline-block-overflow.browser.test.ts b/apps/readest-app/src/__tests__/document/paginator-inline-block-overflow.browser.test.ts new file mode 100644 index 00000000..88cd614a --- /dev/null +++ b/apps/readest-app/src/__tests__/document/paginator-inline-block-overflow.browser.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeAll, afterEach } from 'vitest'; +import { DocumentLoader } from '@/libs/document'; +import type { BookDoc } from '@/libs/document'; +import type { Renderer } from '@/types/view'; + +// repro: a chapter whose whole body is wrapped in a div the EPUB declares as +// `display: inline-block`. Atomic inline boxes can't fragment across columns, +// so in paginated mode the tall box overflows the page vertically and every +// column past the first is clipped — the content after the first page (here +// the TAIL_MARKER_SECTION) silently vanishes. +const REPRO_URL = new URL('../fixtures/data/repro-inline-block-overflow.epub', import.meta.url) + .href; + +const loadEPUB = async (url: string) => { + const resp = await fetch(url); + const buffer = await resp.arrayBuffer(); + const name = url.split('/').pop() ?? 'book.epub'; + const file = new File([buffer], name, { type: 'application/epub+zip' }); + const loader = new DocumentLoader(file); + const { book } = await loader.open(); + return book; +}; + +const waitForStabilized = (el: HTMLElement, timeout = 5000) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout); + el.addEventListener( + 'stabilized', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); + +let reproBook: BookDoc; + +describe('Paginator inline-block overflow (browser)', () => { + let paginator: Renderer; + + beforeAll(async () => { + reproBook = await loadEPUB(REPRO_URL); + await import('foliate-js/paginator.js'); + }, 30000); + + const createPaginator = () => { + const el = document.createElement('foliate-paginator') as Renderer; + Object.assign(el.style, { + width: '800px', + height: '600px', + position: 'absolute', + left: '0', + top: '0', + }); + document.body.appendChild(el); + return el; + }; + + afterEach(async () => { + if (paginator) { + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + try { + paginator.destroy(); + } catch { + /* iframe body may already be torn down */ + } + paginator.remove(); + } + }); + + it('paginates an inline-block-wrapped chapter instead of clipping it', async () => { + paginator = createPaginator(); + paginator.open(reproBook); + + const stabilized = waitForStabilized(paginator); + await paginator.goTo({ index: 0 }); + await stabilized; + // allow font-driven relayout to settle + await new Promise((r) => setTimeout(r, 300)); + + const content = paginator.getContents().find((c) => c.index === 0); + expect(content).toBeDefined(); + const doc = content!.doc; + const root = doc.documentElement; + + // In paginated mode the section flows into horizontal columns, so its + // content height must not exceed the page height. The unfragmentable + // inline-block wrapper would blow this up (content stacks vertically), + // clipping everything past the first column. + expect(root.scrollHeight).toBeLessThanOrEqual(root.clientHeight + 2); + + // The wrapper must have been demoted to a fragmentable display. + const wrap = doc.querySelector('.wrap') as HTMLElement; + expect(wrap).toBeTruthy(); + expect(doc.defaultView!.getComputedStyle(wrap).display).toBe('block'); + + // The tail content (which lived past the first page) must be reachable: + // it should sit in a later column, within the page height. + const tail = doc.getElementById('tail') as HTMLElement; + expect(tail).toBeTruthy(); + const tailRect = tail.getBoundingClientRect(); + const rootRect = root.getBoundingClientRect(); + expect(tailRect.left - rootRect.left).toBeGreaterThan(0); + expect(tailRect.top - rootRect.top).toBeLessThan(root.clientHeight); + }); +}); diff --git a/apps/readest-app/src/__tests__/fixtures/data/repro-inline-block-overflow.epub b/apps/readest-app/src/__tests__/fixtures/data/repro-inline-block-overflow.epub new file mode 100644 index 0000000000000000000000000000000000000000..bf465764f85673df8b6fb03caafb43a4408d8cef GIT binary patch literal 2002 zcmWIWW@h1H0D-I3mtyi0SL=uX*&xgT#JQQdsU?*Ksfh&zIho0cC7Jno`l$t_N!nGJ z1pz2Jfr{aP6Ug#)4RO@<^mEe(DT5dXvOsL><~!?v(tm(h3|U!neqKppW?pKMUPW$B zY~MkyLk0pa@Bemf2w!<|cFVG+1>vk;nUi*1P1q$pE9A?2-)fCVg$eietlakgT(|hQ zi-wsI`r!%_bj|cD`)vRfb$*1GNCx1G})-d7@2sWS({au{`g3&@D;Y!_0B_;+2K2-(= zez=B!F38xzc;83RR@23^^*a?)ALPmfOCT9w;j zt22B1`j^|L?>jihz(DT*;itPV#L4b$W+bfHlZo~c7sHmPzRvlJ=p!-=aDmooVrxxQcY>8}N^Zoj%xX#VQ^9GBb7Jbp`MQaZDu_(lHq z1_~#(s0U5_#5R$8`M>2|S@W}bI)vUG?~r>lC5^wTB)KJpbBg;fhQkssP9tzG8a%3h(JF;yS_gNi%;LpT06(CpbT1 znb>oiEX(h!@(yZ68b+hGF2k(iY{5B z{p;=5yMIrAe|+|)C?o8eD`g|QTYNj&l=bUK4$iUzOOj3e~p6Fn9{(<)OWVZ}|6H zGPAMXJY6C=axH=6m)C76A7Rz9>!!@edB+V_?$_BKw(xvWyUoYDm#OZszj!9^ zm}M7#*TxH53PL7mubegO$)+Q}dS>zD`^{V?;Hg{UIZyY(y#S$1Gmmh+#Y?u{lQ*2= zc(K{{UQf^D%+TALB6}C!_#l1pmIdc`v;MagJAC|Y%i7oFtA0%Vnk9Ct@Bgkj3u-Rg zdd_$xcxdsw4|o4E1b8zt$uZ-uAAq3&21^=26s)#D*9B1nAwhKm1PCy^b+iI9k!lK% zWw`4KkTzh@Gc0LLMAn8o|e4+#JO literal 0 HcmV?d00001 diff --git a/packages/foliate-js b/packages/foliate-js index 4aa4efe3..f5180159 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 4aa4efe3bb372fca472df6e9c0b47d63e0103789 +Subproject commit f518015950087e07692d9a11188db308403f943b