const fs = require('fs/promises'); const path = require('path'); const { formatMarkedText, parseMarkedText, finalizeChapters, detectLayoutChapters, detectChapters, syntheticChapters } = require('../src/structure'); const { buildSamplePdf } = require('./helpers/samplePdf'); const { createLogger } = require('../src/logger'); const logger = createLogger('silent'); function pagesFrom(count) { return Array.from({ length: count }, (_, index) => ({ page: index + 1, text: `Page ${index + 1} body` })); } describe('marked text', () => { it('round-trips page text and chapter markers', () => { const pages = [ { page: 1, text: 'Title page' }, { page: 2, text: 'Chapter body' }, ]; const chapters = [ { chapterNo: 0, title: 'Front matter', startPage: 1, endPage: 1 }, { chapterNo: 1, title: 'The River', startPage: 2, endPage: 2 }, ]; const marked = formatMarkedText(pages, chapters); expect(marked.match(/\[Page \d+\]/g)).toEqual(['[Page 1]', '[Page 2]']); expect(marked).toContain('=== CHAPTER 1: The River ==='); const parsed = parseMarkedText(marked); expect(parsed.pages.map((page) => page.text)).toEqual(['Title page', 'Chapter body']); expect(parsed.markers.map((marker) => marker.chapterNo)).toEqual([0, 1]); expect(formatMarkedText(parsed.pages, chapters)).toBe(marked); }); }); describe('chapter detection', () => { it('uses a PDF outline and treats earlier pages as front matter', async () => { const pdf = await buildSamplePdf(); await fs.mkdir(path.join(__dirname, 'fixtures'), { recursive: true }); await fs.writeFile(path.join(__dirname, 'fixtures/sample-5page.pdf'), pdf); const pages = pagesFrom(5); const detected = await detectChapters({ pdfBytes: pdf, blocks: [], pages, llm: null, logger }); expect(detected.chapterDetectionMethod).toBe('outline'); expect(detected.chapters[0]).toMatchObject({ chapterNo: 0, startPage: 1 }); expect(detected.chapters.map((chapter) => chapter.startPage)).toEqual([1, 2, 4]); expect(detected.chapters[2].endPage).toBe(5); const starts = detected.chapters.map((chapter) => chapter.startPage); expect([...starts].sort((a, b) => a - b)).toEqual(starts); }); it('uses layout headings near the top of the page', () => { const blocks = [ { Id: 'title', BlockType: 'LAYOUT_TITLE', Page: 3, Text: 'Chapter 1 The River', Geometry: { BoundingBox: { Top: 0.08, Left: 0.1, Width: 0.5, Height: 0.03 } }, }, { Id: 'line', BlockType: 'LINE', Page: 3, Text: 'Chapter 1 The River', Geometry: { BoundingBox: { Top: 0.08, Left: 0.1, Width: 0.5, Height: 0.03 } }, }, ]; const found = detectLayoutChapters(blocks, 6); const chapters = finalizeChapters(found, 6); expect(chapters.some((chapter) => chapter.title.includes('River'))).toBe(true); expect(chapters[0].chapterNo).toBe(0); expect(chapters[0].endPage).toBe(2); }); it('asks the model when no outline or headings exist', async () => { const pages = pagesFrom(4).map((page, index) => ({ ...page, text: index === 1 ? 'Chapter 2 Harbor\nShips leave.' : page.text, })); const llm = { async complete() { return { data: { chapters: [{ title: 'Harbor', startPage: 2 }] }, usage: { inputTokens: 1, outputTokens: 1 } }; }, }; const detected = await detectChapters({ pdfBytes: null, blocks: [], pages, llm, logger }); expect(detected.chapterDetectionMethod).toBe('llm'); expect(detected.chapters.map((chapter) => chapter.chapterNo)).toEqual([0, 1]); }); it('builds synthetic sections when nothing else matches', async () => { const pages = pagesFrom(22); const detected = await detectChapters({ pdfBytes: null, blocks: [], pages, llm: null, logger }); expect(detected.chapterDetectionMethod).toBe('synthetic'); expect(detected.chapters.every((chapter) => chapter.synthetic)).toBe(true); expect(detected.chapters[0].startPage).toBe(1); expect(detected.chapters[detected.chapters.length - 1].endPage).toBe(22); for (let index = 1; index < detected.chapters.length; index += 1) { expect(detected.chapters[index].startPage).toBe(detected.chapters[index - 1].endPage + 1); } expect(syntheticChapters(22).length).toBeGreaterThan(1); }); });