#160 export: fixed svg, added size parameter

This commit is contained in:
Thibault Deckers 2022-01-20 12:33:45 +09:00
parent f7183156bf
commit e548134d30
12 changed files with 176 additions and 40 deletions

View file

@ -13,7 +13,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.SvgThumbnail import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.decoder.VideoThumbnail import deckers.thibault.aves.decoder.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
@ -128,7 +128,7 @@ class ThumbnailFetcher internal constructor(
.submit(width, height) .submit(width, height)
} else { } else {
val model: Any = when { val model: Any = when {
svgFetch -> SvgThumbnail(context, uri) svgFetch -> SvgImage(context, uri)
tiffFetch -> TiffImage(context, uri, pageId) tiffFetch -> TiffImage(context, uri, pageId)
multiTrackFetch -> MultiTrackImage(context, uri, pageId) multiTrackFetch -> MultiTrackImage(context, uri, pageId)
else -> StorageUtils.getGlideSafeUri(uri, mimeType) else -> StorageUtils.getGlideSafeUri(uri, mimeType)

View file

@ -134,8 +134,10 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
var destinationDir = arguments["destinationPath"] as String? var destinationDir = arguments["destinationPath"] as String?
val mimeType = arguments["mimeType"] as String? val mimeType = arguments["mimeType"] as String?
val width = arguments["width"] as Int?
val height = arguments["height"] as Int?
val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?) val nameConflictStrategy = NameConflictStrategy.get(arguments["nameConflictStrategy"] as String?)
if (destinationDir == null || mimeType == null || nameConflictStrategy == null) { if (destinationDir == null || mimeType == null || width == null || height == null || nameConflictStrategy == null) {
error("export-args", "failed because of missing arguments", null) error("export-args", "failed because of missing arguments", null)
return return
} }
@ -150,7 +152,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry) val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(activity, mimeType, destinationDir, entries, nameConflictStrategy, object : ImageOpCallback { provider.exportMultiple(activity, mimeType, destinationDir, entries, width, height, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields) override fun onSuccess(fields: FieldMap) = success(fields)
override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable) override fun onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
}) })

View file

@ -25,27 +25,27 @@ import kotlin.math.ceil
@GlideModule @GlideModule
class SvgGlideModule : LibraryGlideModule() { class SvgGlideModule : LibraryGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) { override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
registry.append(SvgThumbnail::class.java, Bitmap::class.java, SvgLoader.Factory()) registry.append(SvgImage::class.java, Bitmap::class.java, SvgLoader.Factory())
} }
} }
class SvgThumbnail(val context: Context, val uri: Uri) class SvgImage(val context: Context, val uri: Uri)
internal class SvgLoader : ModelLoader<SvgThumbnail, Bitmap> { internal class SvgLoader : ModelLoader<SvgImage, Bitmap> {
override fun buildLoadData(model: SvgThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> { override fun buildLoadData(model: SvgImage, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
return ModelLoader.LoadData(ObjectKey(model.uri), SvgFetcher(model, width, height)) return ModelLoader.LoadData(ObjectKey(model.uri), SvgFetcher(model, width, height))
} }
override fun handles(model: SvgThumbnail): Boolean = true override fun handles(model: SvgImage): Boolean = true
internal class Factory : ModelLoaderFactory<SvgThumbnail, Bitmap> { internal class Factory : ModelLoaderFactory<SvgImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<SvgThumbnail, Bitmap> = SvgLoader() override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<SvgImage, Bitmap> = SvgLoader()
override fun teardown() {} override fun teardown() {}
} }
} }
internal class SvgFetcher(val model: SvgThumbnail, val width: Int, val height: Int) : DataFetcher<Bitmap> { internal class SvgFetcher(val model: SvgImage, val width: Int, val height: Int) : DataFetcher<Bitmap> {
override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) { override fun loadData(priority: Priority, callback: DataFetcher.DataCallback<in Bitmap>) {
val context = model.context val context = model.context
val uri = model.uri val uri = model.uri

View file

@ -16,6 +16,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.RequestOptions
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.* import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
@ -82,6 +83,8 @@ abstract class ImageProvider {
imageExportMimeType: String, imageExportMimeType: String,
targetDir: String, targetDir: String,
entries: List<AvesEntry>, entries: List<AvesEntry>,
width: Int,
height: Int,
nameConflictStrategy: NameConflictStrategy, nameConflictStrategy: NameConflictStrategy,
callback: ImageOpCallback, callback: ImageOpCallback,
) { ) {
@ -120,6 +123,8 @@ abstract class ImageProvider {
sourceEntry = entry, sourceEntry = entry,
targetDir = targetDir, targetDir = targetDir,
targetDirDocFile = targetDirDocFile, targetDirDocFile = targetDirDocFile,
width = width,
height = height,
nameConflictStrategy = nameConflictStrategy, nameConflictStrategy = nameConflictStrategy,
exportMimeType = exportMimeType, exportMimeType = exportMimeType,
) )
@ -138,6 +143,8 @@ abstract class ImageProvider {
sourceEntry: AvesEntry, sourceEntry: AvesEntry,
targetDir: String, targetDir: String,
targetDirDocFile: DocumentFileCompat, targetDirDocFile: DocumentFileCompat,
width: Int,
height: Int,
nameConflictStrategy: NameConflictStrategy, nameConflictStrategy: NameConflictStrategy,
exportMimeType: String, exportMimeType: String,
): FieldMap { ): FieldMap {
@ -178,6 +185,8 @@ abstract class ImageProvider {
MultiTrackImage(activity, sourceUri, pageId) MultiTrackImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) { } else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(activity, sourceUri, pageId) TiffImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.SVG) {
SvgImage(activity, sourceUri)
} else { } else {
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType) StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
} }
@ -192,7 +201,7 @@ abstract class ImageProvider {
.asBitmap() .asBitmap()
.apply(glideOptions) .apply(glideOptions)
.load(model) .load(model)
.submit() .submit(width, height)
try { try {
var bitmap = target.get() var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) { if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {

View file

@ -291,6 +291,8 @@
}, },
"exportEntryDialogFormat": "Format:", "exportEntryDialogFormat": "Format:",
"exportEntryDialogWidth": "Width",
"exportEntryDialogHeight": "Height",
"renameEntryDialogLabel": "New name", "renameEntryDialogLabel": "New name",

View file

@ -179,6 +179,8 @@
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer ces albums et leur élément ?} other{Voulez-vous vraiment supprimer ces albums et leurs {count} éléments ?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Voulez-vous vraiment supprimer ces albums et leur élément ?} other{Voulez-vous vraiment supprimer ces albums et leurs {count} éléments ?}}",
"exportEntryDialogFormat": "Format :", "exportEntryDialogFormat": "Format :",
"exportEntryDialogWidth": "Largeur",
"exportEntryDialogHeight": "Hauteur",
"renameEntryDialogLabel": "Nouveau nom", "renameEntryDialogLabel": "Nouveau nom",

View file

@ -179,6 +179,8 @@
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}", "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}",
"exportEntryDialogFormat": "형식:", "exportEntryDialogFormat": "형식:",
"exportEntryDialogWidth": "가로",
"exportEntryDialogHeight": "세로",
"renameEntryDialogLabel": "이름", "renameEntryDialogLabel": "이름",

View file

@ -10,6 +10,7 @@ import 'package:aves/services/common/output_buffer.dart';
import 'package:aves/services/common/service_policy.dart'; import 'package:aves/services/common/service_policy.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
@ -87,7 +88,7 @@ abstract class MediaFileService {
Stream<ExportOpEvent> export( Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
required String mimeType, required EntryExportOptions options,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy, required NameConflictStrategy nameConflictStrategy,
}); });
@ -368,7 +369,7 @@ class PlatformMediaFileService implements MediaFileService {
@override @override
Stream<ExportOpEvent> export( Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, { Iterable<AvesEntry> entries, {
required String mimeType, required EntryExportOptions options,
required String destinationAlbum, required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy, required NameConflictStrategy nameConflictStrategy,
}) { }) {
@ -377,7 +378,9 @@ class PlatformMediaFileService implements MediaFileService {
.receiveBroadcastStream(<String, dynamic>{ .receiveBroadcastStream(<String, dynamic>{
'op': 'export', 'op': 'export',
'entries': entries.map(_toPlatformEntryMap).toList(), 'entries': entries.map(_toPlatformEntryMap).toList(),
'mimeType': mimeType, 'mimeType': options.mimeType,
'width': options.width,
'height': options.height,
'destinationPath': destinationAlbum, 'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(), 'nameConflictStrategy': nameConflictStrategy.toPlatform(),
}) })
@ -434,3 +437,18 @@ class PlatformMediaFileService implements MediaFileService {
return {}; return {};
} }
} }
@immutable
class EntryExportOptions extends Equatable {
final String mimeType;
final int width, height;
@override
List<Object?> get props => [mimeType, width, height];
const EntryExportOptions({
required this.mimeType,
required this.width,
required this.height,
});
}

View file

@ -293,7 +293,10 @@ class _GeoMapState extends State<GeoMap> {
// node size: 64 by default, higher means faster indexing but slower search // node size: 64 by default, higher means faster indexing but slower search
nodeSize: nodeSize, nodeSize: nodeSize,
points: markers, points: markers,
createCluster: GeoEntry.createCluster, // use lambda instead of tear-off because of runtime exception when using
// `T Function(BaseCluster, double, double)` for `T Function(BaseCluster?, double?, double?)`
// ignore: unnecessary_lambdas
createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat),
); );
} }

View file

@ -1,5 +1,6 @@
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/utils/mime_utils.dart'; import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -19,6 +20,8 @@ class ExportEntryDialog extends StatefulWidget {
} }
class _ExportEntryDialogState extends State<ExportEntryDialog> { class _ExportEntryDialogState extends State<ExportEntryDialog> {
final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
String _mimeType = MimeTypes.jpeg; String _mimeType = MimeTypes.jpeg;
AvesEntry get entry => widget.entry; AvesEntry get entry => widget.entry;
@ -30,27 +33,80 @@ class _ExportEntryDialogState extends State<ExportEntryDialog> {
MimeTypes.webp, MimeTypes.webp,
]; ];
@override
void initState() {
super.initState();
_widthController.text = '${entry.isRotated ? entry.height : entry.width}';
_heightController.text = '${entry.isRotated ? entry.width : entry.height}';
_validate();
}
@override
void dispose() {
_widthController.dispose();
_heightController.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n;
return AvesDialog( return AvesDialog(
content: Row( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(context.l10n.exportEntryDialogFormat), Row(
const SizedBox(width: AvesDialog.controlCaptionPadding), mainAxisSize: MainAxisSize.min,
DropdownButton<String>( children: [
items: imageExportFormats.map((mimeType) { Text(l10n.exportEntryDialogFormat),
return DropdownMenuItem<String>( const SizedBox(width: AvesDialog.controlCaptionPadding),
value: mimeType, DropdownButton<String>(
child: Text(MimeUtils.displayType(mimeType)), items: imageExportFormats.map((mimeType) {
); return DropdownMenuItem<String>(
}).toList(), value: mimeType,
value: _mimeType, child: Text(MimeUtils.displayType(mimeType)),
onChanged: (selected) { );
if (selected != null) { }).toList(),
setState(() => _mimeType = selected); value: _mimeType,
} onChanged: (selected) {
}, if (selected != null) {
setState(() => _mimeType = selected);
}
},
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: TextField(
controller: _widthController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogWidth),
keyboardType: TextInputType.number,
onChanged: (value) {
final width = int.tryParse(value);
_heightController.text = width != null ? '${(width / entry.displayAspectRatio).round()}' : '';
_validate();
},
),
),
const Text(AvesEntry.resolutionSeparator),
Expanded(
child: TextField(
controller: _heightController,
decoration: InputDecoration(labelText: l10n.exportEntryDialogHeight),
keyboardType: TextInputType.number,
onChanged: (value) {
final height = int.tryParse(value);
_widthController.text = height != null ? '${(height * entry.displayAspectRatio).round()}' : '';
_validate();
},
),
),
],
), ),
], ],
), ),
@ -59,11 +115,35 @@ class _ExportEntryDialogState extends State<ExportEntryDialog> {
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel), child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
), ),
TextButton( ValueListenableBuilder<bool>(
onPressed: () => Navigator.pop(context, _mimeType), valueListenable: _isValidNotifier,
child: Text(context.l10n.applyButtonLabel), builder: (context, isValid, child) {
) return TextButton(
onPressed: isValid
? () {
final width = int.tryParse(_widthController.text);
final height = int.tryParse(_heightController.text);
final options = (width != null && height != null)
? EntryExportOptions(
mimeType: _mimeType,
width: width,
height: height,
)
: null;
Navigator.pop(context, options);
}
: null,
child: Text(l10n.applyButtonLabel),
);
},
),
], ],
); );
} }
Future<void> _validate() async {
final width = int.tryParse(_widthController.text);
final height = int.tryParse(_heightController.text);
_isValidNotifier.value = (width ?? 0) > 0 && (height ?? 0) > 0;
}
} }

View file

@ -14,6 +14,7 @@ import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/image_op_events.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves/services/media/enums.dart'; import 'package:aves/services/media/enums.dart';
import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
@ -203,11 +204,11 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return; if (!await checkFreeSpaceForMove(context, {entry}, destinationAlbum, MoveType.export)) return;
final mimeType = await showDialog<String>( final options = await showDialog<EntryExportOptions>(
context: context, context: context,
builder: (context) => ExportEntryDialog(entry: entry), builder: (context) => ExportEntryDialog(entry: entry),
); );
if (mimeType == null) return; if (options == null) return;
final selection = <AvesEntry>{}; final selection = <AvesEntry>{};
if (entry.isMultiPage) { if (entry.isMultiPage) {
@ -231,7 +232,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
// TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures) // TODO TLAD [SVG] export separately from raster images (sending bytes, like frame captures)
opStream: mediaFileService.export( opStream: mediaFileService.export(
selection, selection,
mimeType: mimeType, options: options,
destinationAlbum: destinationAlbum, destinationAlbum: destinationAlbum,
nameConflictStrategy: NameConflictStrategy.rename, nameConflictStrategy: NameConflictStrategy.rename,
), ),

View file

@ -1,5 +1,22 @@
{ {
"de": [
"exportEntryDialogWidth",
"exportEntryDialogHeight"
],
"es": [
"exportEntryDialogWidth",
"exportEntryDialogHeight"
],
"pt": [
"exportEntryDialogWidth",
"exportEntryDialogHeight"
],
"ru": [ "ru": [
"exportEntryDialogWidth",
"exportEntryDialogHeight",
"appExportCovers", "appExportCovers",
"settingsThumbnailShowFavouriteIcon" "settingsThumbnailShowFavouriteIcon"
] ]