diff --git a/lib/editor/layout/double_page_layout.dart b/lib/editor/layout/double_page_layout.dart new file mode 100644 index 0000000..b57fabc --- /dev/null +++ b/lib/editor/layout/double_page_layout.dart @@ -0,0 +1,75 @@ +// lib/editor/layout/double_page_layout.dart +// +// Two-up (spread) layout foundation for book-like reading (F2/F3 — the user's +// explicit "two-page spread" ask). Pages are grouped into spread ROWS; each row +// then stacks vertically exactly like continuous-single, so windowing reuses +// PageStackMetrics on the row heights. +// +// Pure geometry (no widgets / pdfrx): the row-mounting widget is device-gated; +// the pairing + row-height math is here, fully unit-tested. Building it ahead of +// its phase is zero-rework-risk (it is not rendering and the P0.5 perf gate +// can't invalidate pure geometry). + +import 'page_viewport.dart'; +import '../pdf/pdf_document_source.dart'; + +/// Groups page indices into two-up spread rows. With [coverAlone], page 0 sits +/// on its own row (a book cover / title page) and the rest pair 1-2, 3-4, …; +/// otherwise pages pair from 0. A trailing odd page occupies a single-page row. +List> pairIntoRows(int pageCount, {bool coverAlone = false}) { + assert(pageCount >= 0); + final rows = >[]; + var i = 0; + if (coverAlone && pageCount > 0) { + rows.add([0]); + i = 1; + } + while (i < pageCount) { + if (i + 1 < pageCount) { + rows.add([i, i + 1]); + i += 2; + } else { + rows.add([i]); + i += 1; + } + } + return rows; +} + +/// Height of each spread row when each page is fit to HALF of [columnWidth] +/// (two pages share the column). A row's height is the tallest of its pages so +/// both pages sit on a common baseline. Non-positive page widths contribute 0. +List spreadRowHeights( + PageDocumentSource source, + double columnWidth, + List> rows, +) { + assert(columnWidth >= 0); + final half = columnWidth / 2; + return rows.map((row) { + var tallest = 0.0; + for (final pageIndex in row) { + final size = source.pageSize(pageIndex); + if (size.width <= 0) continue; + final fit = half * (size.height / size.width); + if (fit > tallest) tallest = fit; + } + return tallest; + }).toList(growable: false); +} + +/// Continuous-DOUBLE stacking metrics: pair pages into spread rows, then stack +/// the rows vertically. [PageStackMetrics.visibleRange] over the result yields +/// the visible ROW range; map rows back to pages via the [pairIntoRows] result. +PageStackMetrics spreadStackMetrics( + PageDocumentSource source, + double columnWidth, { + bool coverAlone = false, + double gap = 0.0, +}) { + final rows = pairIntoRows(source.pageCount, coverAlone: coverAlone); + return PageStackMetrics( + pageHeights: spreadRowHeights(source, columnWidth, rows), + gap: gap, + ); +} diff --git a/test/double_page_layout_test.dart b/test/double_page_layout_test.dart new file mode 100644 index 0000000..ff21fc1 --- /dev/null +++ b/test/double_page_layout_test.dart @@ -0,0 +1,96 @@ +// Tests for the two-up spread layout foundation (F2/F3). + +import 'dart:ui' show Size; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/layout/double_page_layout.dart'; +import 'package:badnote/editor/pdf/pdf_document_source.dart'; + +class _Src implements PageDocumentSource { + _Src(this._sizes); + final List _sizes; + @override + int get pageCount => _sizes.length; + @override + Size pageSize(int index) => _sizes[index]; +} + +void main() { + group('pairIntoRows', () { + test('pairs from 0 by default', () { + expect(pairIntoRows(4), [ + [0, 1], + [2, 3], + ]); + }); + + test('trailing odd page gets a single-page row', () { + expect(pairIntoRows(5), [ + [0, 1], + [2, 3], + [4], + ]); + }); + + test('coverAlone puts page 0 alone, then pairs', () { + expect(pairIntoRows(5, coverAlone: true), [ + [0], + [1, 2], + [3, 4], + ]); + }); + + test('edge counts', () { + expect(pairIntoRows(0), isEmpty); + expect(pairIntoRows(1), [ + [0], + ]); + expect(pairIntoRows(1, coverAlone: true), [ + [0], + ]); + expect(pairIntoRows(2, coverAlone: true), [ + [0], + [1], + ]); + }); + }); + + group('spreadRowHeights', () { + test('row height is the tallest page at half column width', () { + // page0 600x800 (aspect 800/600), page1 600x400. At column 600 → half 300. + // page0 fit = 300 * 800/600 = 400; page1 fit = 300 * 400/600 = 200. + final src = _Src([const Size(600, 800), const Size(600, 400)]); + final rows = pairIntoRows(2); + expect(spreadRowHeights(src, 600, rows), [400.0]); // max(400,200) + }); + + test('single-page row uses that page only', () { + final src = _Src([const Size(400, 800)]); + final rows = pairIntoRows(1); + // half = 150; fit = 150 * 800/400 = 300. + expect(spreadRowHeights(src, 300, rows), [300.0]); + }); + + test('non-positive width contributes 0', () { + final src = _Src([const Size(0, 800), const Size(400, 400)]); + final rows = pairIntoRows(2); // [[0,1]] + // page0 → 0; page1 half=150 fit=150*400/400=150 → max 150. + expect(spreadRowHeights(src, 300, rows), [150.0]); + }); + }); + + group('spreadStackMetrics composes with windowing', () { + test('rows stack vertically; visibleRange selects visible rows', () { + // 6 portrait pages 600x800 → 3 rows; at column 600 each row 400 tall. + final src = _Src(List.filled(6, const Size(600, 800))); + final m = spreadStackMetrics(src, 600, gap: 0); + expect(m.pageCount, 3); // 3 ROWS + expect(m.totalExtent, closeTo(1200, 1e-9)); // 3 * 400 + // viewport [350,850): row0 [0,400), row1 [400,800), row2 [800,1200). + // first bottom>350 → row0; last top<850 → row2. + expect(m.visibleRange(350, 500).first, 0); + expect(m.visibleRange(350, 500).last, 2); + }); + }); +}