feat(f2): two-page spread layout foundation (pair rows + row heights)
Some checks failed
CI / Windows build (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 03:27:20 +08:00
parent 3e0b81be92
commit 1aa2865d6f
2 changed files with 171 additions and 0 deletions

View File

@@ -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<List<int>> pairIntoRows(int pageCount, {bool coverAlone = false}) {
assert(pageCount >= 0);
final rows = <List<int>>[];
var i = 0;
if (coverAlone && pageCount > 0) {
rows.add(<int>[0]);
i = 1;
}
while (i < pageCount) {
if (i + 1 < pageCount) {
rows.add(<int>[i, i + 1]);
i += 2;
} else {
rows.add(<int>[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<double> spreadRowHeights(
PageDocumentSource source,
double columnWidth,
List<List<int>> 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,
);
}

View File

@@ -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<Size> _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);
});
});
}