From 1aa2865d6f858d26e047af8a20f23117a874393f Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:27:20 +0800 Subject: [PATCH] feat(f2): two-page spread layout foundation (pair rows + row heights) Book-like reading the user explicitly wanted: pairIntoRows groups pages into two-up spread rows (optional coverAlone for a title page; trailing odd page sits alone), spreadRowHeights fits each page to half the column and takes the tallest per row (common baseline), and spreadStackMetrics stacks the rows so the existing PageStackMetrics.visibleRange windows continuous-DOUBLE by ROW. Pure geometry reusing the continuous-single layer; the row-mounting widget is device-gated. Zero-rework-risk (not rendering). flutter analyze lib/editor clean; 172/172 tests (+8). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/layout/double_page_layout.dart | 75 ++++++++++++++++++ test/double_page_layout_test.dart | 96 +++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 lib/editor/layout/double_page_layout.dart create mode 100644 test/double_page_layout_test.dart 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); + }); + }); +}