From 6b7cc14836afe958b06d5f7089293af978521a34 Mon Sep 17 00:00:00 2001 From: Akiba So Date: Tue, 23 Jun 2026 03:10:05 +0800 Subject: [PATCH] feat(p0.5): PageDocumentSource seam + fit-to-width metrics glue (step 10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connects the two pure P0.5 pieces: a minimal PageDocumentSource abstraction (pageCount + pageSize, a pdfrx PdfDocument in production) and pageStackMetricsForWidth() which fits every page to a single column width (continuous-single) — height = columnWidth × aspect — feeding PageStackMetrics.visibleRange. Defensive against non-positive page width. This makes the windowing math consumable + unit-testable against a fake source (no pdfium/GPU); the production pdfrx adapter is the thin device-side wrapper added with the page-mounting widget. flutter analyze lib/editor clean; 120/120 tests (+5: fit-to-width, gap, empty, zero-width guard, windowing composition). Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/editor/pdf/pdf_document_source.dart | 45 +++++++++++++++++ test/pdf_document_source_test.dart | 64 +++++++++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 lib/editor/pdf/pdf_document_source.dart create mode 100644 test/pdf_document_source_test.dart diff --git a/lib/editor/pdf/pdf_document_source.dart b/lib/editor/pdf/pdf_document_source.dart new file mode 100644 index 0000000..58ada7f --- /dev/null +++ b/lib/editor/pdf/pdf_document_source.dart @@ -0,0 +1,45 @@ +// lib/editor/pdf/pdf_document_source.dart +// +// A minimal abstraction over a paginated document (a pdfrx PdfDocument in +// production) consumed by the layout + render layer. Keeping the layout math +// behind this seam lets continuous-single windowing (layout/page_viewport.dart) +// and tiling be unit-tested against a fake source — no pdfium, no GPU, no real +// PDF (the production pdfrx adapter is a thin device-side wrapper added with the +// viewport widget / page_tile renderer, which are device-gated). + +import 'dart:ui' show Size; + +import '../layout/page_viewport.dart'; + +/// Read-only page geometry for a paginated document. +abstract class PageDocumentSource { + /// Number of pages (>= 0). + int get pageCount; + + /// Intrinsic size of page [index] in PDF points (width/height > 0). + Size pageSize(int index); +} + +/// Builds continuous-single stacking metrics by fitting every page to a single +/// [columnWidth] (fit-to-width, the continuous-single mode): each page's +/// laid-out height is `columnWidth × (pageHeight / pageWidth)`, preserving its +/// aspect ratio. [gap] is inserted between pages (content units). +/// +/// Pages reporting a non-positive width are treated as zero-height (defensive; +/// real pages always have a positive width) so a malformed page can't throw. +/// +/// Returns the metrics needed by [PageStackMetrics.visibleRange]; pair this with +/// the device-gated page-mounting widget. +PageStackMetrics pageStackMetricsForWidth( + PageDocumentSource source, + double columnWidth, { + double gap = 0.0, +}) { + assert(columnWidth >= 0); + final heights = List.generate(source.pageCount, (i) { + final size = source.pageSize(i); + if (size.width <= 0) return 0.0; + return columnWidth * (size.height / size.width); + }); + return PageStackMetrics(pageHeights: heights, gap: gap); +} diff --git a/test/pdf_document_source_test.dart b/test/pdf_document_source_test.dart new file mode 100644 index 0000000..eae73a0 --- /dev/null +++ b/test/pdf_document_source_test.dart @@ -0,0 +1,64 @@ +// Tests for the document→layout glue (P0.5 step 10, automatable slice): +// fit-to-width metrics built from a fake PageDocumentSource. No pdfium. + +import 'dart:ui' show Size; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:badnote/editor/layout/page_viewport.dart'; +import 'package:badnote/editor/pdf/pdf_document_source.dart'; + +class _FakeSource implements PageDocumentSource { + _FakeSource(this._sizes); + final List _sizes; + @override + int get pageCount => _sizes.length; + @override + Size pageSize(int index) => _sizes[index]; +} + +void main() { + test('fit-to-width scales each page height by its aspect at the column width', + () { + // A4-ish portrait 600×800 and a square 500×500 at column width 300. + final source = _FakeSource([const Size(600, 800), const Size(500, 500)]); + final m = pageStackMetricsForWidth(source, 300); + expect(m.pageCount, 2); + expect(m.heightOf(0), closeTo(300 * 800 / 600, 1e-9)); // 400 + expect(m.heightOf(1), closeTo(300.0, 1e-9)); // square → 300 + expect(m.totalExtent, closeTo(700, 1e-9)); + }); + + test('gap is threaded into the metrics', () { + final source = _FakeSource([const Size(100, 100), const Size(100, 100)]); + final m = pageStackMetricsForWidth(source, 100, gap: 25); + expect(m.offsetOf(1), closeTo(125, 1e-9)); // 100 + 25 gap + }); + + test('empty document yields empty metrics', () { + final m = pageStackMetricsForWidth(_FakeSource(const []), 300); + expect(m.pageCount, 0); + expect(m.totalExtent, 0); + }); + + test('non-positive page width is treated as zero height (no divide-by-zero)', + () { + final source = _FakeSource([const Size(0, 800), const Size(400, 200)]); + final m = pageStackMetricsForWidth(source, 300); + expect(m.heightOf(0), 0.0); + expect(m.heightOf(1), closeTo(150, 1e-9)); // 300 * 200/400 + }); + + test('windowing composes on top of the fit-to-width metrics', () { + // 5 portrait pages 600×800 at width 300 → each 400 tall, total 2000. + final source = + _FakeSource(List.filled(5, const Size(600, 800))); + final m = pageStackMetricsForWidth(source, 300); + expect(m.totalExtent, closeTo(2000, 1e-9)); + // viewport [350, 850) → page1 [400,800) and page2 [800,1200) just touched? + // page0 [0,400), page1 [400,800), page2 [800,1200). window [350,850]: + // first bottom>350 → page0 bottom 400>350 → 0; last top<850 → page2 top + // 800<850 → 2. + expect(m.visibleRange(350, 500), const PageWindow(0, 2)); + }); +}