Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions example/test/widget_test.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:papyrus_reader/papyrus_reader.dart';
import 'package:papyrus_reader_example/main.dart';
Expand Down Expand Up @@ -30,6 +31,9 @@ void main() {
await tester.pumpAndSettle();

expect(find.byType(PapyrusReader), findsOneWidget);
expect(find.byType(Slider), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();

final progressSlider = tester.widget<Slider>(find.byType(Slider));
progressSlider.onChangeEnd!(0.65);
Expand Down Expand Up @@ -61,6 +65,9 @@ void main() {
await tester.tap(readEpub);
await tester.pumpAndSettle();
expect(find.byType(PapyrusReader), findsOneWidget);
expect(find.byType(Slider), findsNothing);
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(tester.widget<Slider>(find.byType(Slider)).value, 1.0);

await tester.tap(find.byTooltip('Reading settings'));
Expand Down
1 change: 1 addition & 0 deletions lib/papyrus_reader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export 'src/domain/reader_activity.dart';
export 'src/domain/reader_document.dart';
export 'src/domain/reader_exception.dart';
export 'src/domain/reader_locator.dart';
export 'src/domain/reader_selection.dart';
export 'src/domain/reader_preferences.dart';
export 'src/domain/reader_snapshot.dart';
export 'src/domain/reader_toc_entry.dart';
Expand Down
2 changes: 1 addition & 1 deletion lib/src/domain/reader_preferences.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ final class ReaderPreferences {
this.foregroundColor = const Color(0xff1b1b1b),
this.brightness = Brightness.light,
this.layoutMode = ReaderLayoutMode.paginated,
this.columnMode = ReaderColumnMode.automatic,
this.columnMode = ReaderColumnMode.single,
}) : assert(fontSize > 0),
assert(lineHeight > 0),
assert(paragraphSpacing >= 0);
Expand Down
82 changes: 82 additions & 0 deletions lib/src/domain/reader_selection.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import 'package:flutter/painting.dart';

import 'reader_locator.dart';

/// Durable text anchors. EPUB uses chapter-local UTF-16 offsets, PDF uses page rectangles.
final class ReaderSelection {
const ReaderSelection({
required this.text,
required this.start,
required this.end,
this.rectangles = const [],
});

final String text;
final ReaderLocator start;
final ReaderLocator end;
final List<ReaderSelectionRect> rectangles;

Map<String, Object?> toJson() => {
'text': text,
'start': start.toJson(),
'end': end.toJson(),
'rectangles': [for (final rectangle in rectangles) rectangle.toJson()],
};

factory ReaderSelection.fromJson(Map<String, Object?> json) =>
ReaderSelection(
text: json['text'] as String,
start: ReaderLocator.fromJson(
Map<String, Object?>.from(json['start'] as Map),
),
end: ReaderLocator.fromJson(
Map<String, Object?>.from(json['end'] as Map),
),
rectangles: [
for (final rectangle in json['rectangles'] as List? ?? const [])
ReaderSelectionRect.fromJson(
Map<String, Object?>.from(rectangle as Map),
),
],
);
}

final class ReaderSelectionRect {
const ReaderSelectionRect({required this.pageIndex, required this.rect});

final int pageIndex;

/// Coordinates relative to page width and height, with a top-left origin.
final Rect rect;

Map<String, Object?> toJson() => {
'pageIndex': pageIndex,
'left': rect.left,
'top': rect.top,
'right': rect.right,
'bottom': rect.bottom,
};

factory ReaderSelectionRect.fromJson(Map<String, Object?> json) =>
ReaderSelectionRect(
pageIndex: json['pageIndex'] as int,
rect: Rect.fromLTRB(
(json['left'] as num).toDouble(),
(json['top'] as num).toDouble(),
(json['right'] as num).toDouble(),
(json['bottom'] as num).toDouble(),
),
);
}

final class ReaderHighlight {
const ReaderHighlight({
required this.id,
required this.selection,
required this.color,
});

final String id;
final ReaderSelection selection;
final Color color;
}
25 changes: 25 additions & 0 deletions lib/src/engine/epub/epub_reader_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ final class EpubReaderEngine extends ReaderEngine {
];
_anchors = (chapter['anchors'] as Map).cast<String, int>();
_contentLength = chapter['length'] as int;
_toc = [
for (final entry in _toc) _resolveTocEntry(entry, target.spineIndex),
];
final offset = target.anchor == null
? target.textOffset
: _anchors[target.anchor] ?? target.textOffset;
Expand All @@ -214,6 +217,28 @@ final class EpubReaderEngine extends ReaderEngine {
_restorationRevision++;
}

ReaderTocEntry _resolveTocEntry(ReaderTocEntry entry, int spineIndex) {
final locator = entry.locator as EpubReaderLocator;
int? offset;
if (locator.spineIndex == spineIndex) {
offset = locator.anchor == null ? 0 : _anchors[locator.anchor];
}
return ReaderTocEntry(
title: entry.title,
locator: offset == null
? locator
: _locatorFor(
spineIndex,
textOffset: offset,
local: _contentLength == 0 ? 0 : offset / _contentLength,
anchor: locator.anchor,
),
children: [
for (final child in entry.children) _resolveTocEntry(child, spineIndex),
],
);
}

void _requireCurrent(int generation) {
if (_disposed || generation != _navigationGeneration) {
throw const ReaderException(
Expand Down
175 changes: 157 additions & 18 deletions lib/src/engine/epub/epub_rich_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import 'dart:math' as math;
import 'dart:typed_data';

import 'package:flutter/widgets.dart';
import 'package:flutter/rendering.dart';

import '../../domain/reader_preferences.dart';
import '../../domain/reader_selection.dart';
import '../../domain/reader_locator.dart';
import '../../presentation/reader_interaction_scope.dart';

final class EpubContentBlock {
Expand Down Expand Up @@ -49,28 +52,71 @@ final class EpubContentBlock {
);
}

TextSpan span(ReaderPreferences preferences, {int start = 0, int? end}) {
TextSpan span(
ReaderPreferences preferences, {
int start = 0,
int? end,
List<ReaderHighlight> highlights = const [],
}) {
final children = <TextSpan>[];
var position = 0;
for (final run in runs) {
final text = run['text'] as String;
final from = math.max(0, start - position);
final to = math.min(text.length, (end ?? length) - position);
if (to > from) {
children.add(
TextSpan(
text: text.substring(from, to),
style: TextStyle(
fontWeight: run['bold'] == true ? FontWeight.bold : null,
fontStyle: run['italic'] == true ? FontStyle.italic : null,
fontFamily: run['code'] == true ? 'monospace' : null,
decoration: TextDecoration.combine([
if (run['underline'] == true) TextDecoration.underline,
if (run['strike'] == true) TextDecoration.lineThrough,
]),
final cuts = <int>{from, to};

for (final highlight in highlights) {
final first = highlight.selection.start;
final last = highlight.selection.end;

if (first is EpubReaderLocator &&
last is EpubReaderLocator &&
first.textOffset != null &&
last.textOffset != null) {
cuts.add((first.textOffset! - offset - position).clamp(from, to));
cuts.add((last.textOffset! - offset - position).clamp(from, to));
}
}

final boundaries = cuts.toList()..sort();

for (var index = 0; index < boundaries.length - 1; index++) {
final segmentStart = boundaries[index];
Color? background;

for (final highlight in highlights) {
final first = highlight.selection.start;
final last = highlight.selection.end;
final absolute = offset + position + segmentStart;

if (first is EpubReaderLocator &&
last is EpubReaderLocator &&
first.textOffset != null &&
last.textOffset != null &&
absolute >= first.textOffset! &&
absolute < last.textOffset!) {
background = highlight.color.withValues(alpha: .4);
}
}

children.add(
TextSpan(
text: text.substring(segmentStart, boundaries[index + 1]),
style: TextStyle(
backgroundColor: background,
fontWeight: run['bold'] == true ? FontWeight.bold : null,
fontStyle: run['italic'] == true ? FontStyle.italic : null,
fontFamily: run['code'] == true ? 'monospace' : null,
decoration: TextDecoration.combine([
if (run['underline'] == true) TextDecoration.underline,
if (run['strike'] == true) TextDecoration.lineThrough,
]),
),
),
),
);
);
}
}
position += text.length;
}
Expand Down Expand Up @@ -193,8 +239,9 @@ Future<List<EpubRichPage>> layoutEpubChapter(

Widget buildEpubFragment(
EpubPageFragment fragment,
ReaderPreferences preferences,
) {
ReaderPreferences preferences, {
List<ReaderHighlight> highlights = const [],
}) {
final block = fragment.block;
if (block.image != null) {
return SizedBox(
Expand All @@ -212,8 +259,17 @@ Widget buildEpubFragment(
),
);
}
final text = Text.rich(
block.span(preferences, start: fragment.start, end: fragment.end),
final text = EpubSelectionFragment(
key: ValueKey(fragment.offset),
fragment: fragment,
child: Text.rich(
block.span(
preferences,
start: fragment.start,
end: fragment.end,
highlights: highlights,
),
),
);
if (!block.runs.any((run) => run['link'] == true)) {
return text;
Expand Down Expand Up @@ -254,3 +310,86 @@ Widget buildEpubFragment(
),
);
}

final class EpubSelectionFragment extends StatefulWidget {
const EpubSelectionFragment({
required this.fragment,
required this.child,
super.key,
});
final EpubPageFragment fragment;
final Widget child;

@override
State<EpubSelectionFragment> createState() => EpubSelectionFragmentState();
}

final class EpubSelectionFragmentState extends State<EpubSelectionFragment>
implements SelectionRegistrar {
final _selectables = <Selectable>{};
SelectionRegistrar? _parent;

SelectedContentRange? get range {
for (final selectable in _selectables) {
final selection = selectable.getSelection();
if (selection != null) return selection;
}
return null;
}

List<Rect> rectangles(int start, int end) {
RenderParagraph? paragraph;
void visit(RenderObject object) {
if (object is RenderParagraph) {
paragraph = object;
} else {
object.visitChildren(visit);
}
}

final object = context.findRenderObject();
if (object != null) visit(object);
final text = paragraph;
if (text == null || !text.attached) return const [];
final length = widget.fragment.end - widget.fragment.start;
final selection = TextSelection(
baseOffset: start.clamp(0, length),
extentOffset: end.clamp(0, length),
);
return [
for (final box in text.getBoxesForSelection(selection))
Rect.fromPoints(
text.localToGlobal(box.toRect().topLeft),
text.localToGlobal(box.toRect().bottomRight),
),
];
}

@override
void add(Selectable selectable) {
_selectables.add(selectable);
_parent?.add(selectable);
}

@override
void remove(Selectable selectable) {
_selectables.remove(selectable);
_parent?.remove(selectable);
}

@override
void didChangeDependencies() {
super.didChangeDependencies();
final parent = SelectionContainer.maybeOf(context);
if (identical(parent, _parent)) return;
for (final selectable in _selectables) {
_parent?.remove(selectable);
parent?.add(selectable);
}
_parent = parent;
}

@override
Widget build(BuildContext context) =>
SelectionRegistrarScope(registrar: this, child: widget.child);
}
Loading
Loading