#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.signature.ObjectKey
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.VideoThumbnail
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
@ -128,7 +128,7 @@ class ThumbnailFetcher internal constructor(
.submit(width, height)
} else {
val model: Any = when {
svgFetch -> SvgThumbnail(context, uri)
svgFetch -> SvgImage(context, uri)
tiffFetch -> TiffImage(context, uri, pageId)
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
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?
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?)
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)
return
}
@ -150,7 +152,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir)
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 onFailure(throwable: Throwable) = error("export-failure", "failed to export entries", throwable)
})

View file

@ -25,27 +25,27 @@ import kotlin.math.ceil
@GlideModule
class SvgGlideModule : LibraryGlideModule() {
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> {
override fun buildLoadData(model: SvgThumbnail, width: Int, height: Int, options: Options): ModelLoader.LoadData<Bitmap> {
internal class SvgLoader : ModelLoader<SvgImage, 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))
}
override fun handles(model: SvgThumbnail): Boolean = true
override fun handles(model: SvgImage): Boolean = true
internal class Factory : ModelLoaderFactory<SvgThumbnail, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<SvgThumbnail, Bitmap> = SvgLoader()
internal class Factory : ModelLoaderFactory<SvgImage, Bitmap> {
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<SvgImage, Bitmap> = SvgLoader()
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>) {
val context = model.context
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.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.decoder.MultiTrackImage
import deckers.thibault.aves.decoder.SvgImage
import deckers.thibault.aves.decoder.TiffImage
import deckers.thibault.aves.metadata.*
import deckers.thibault.aves.metadata.ExifInterfaceHelper.getSafeDateMillis
@ -82,6 +83,8 @@ abstract class ImageProvider {
imageExportMimeType: String,
targetDir: String,
entries: List<AvesEntry>,
width: Int,
height: Int,
nameConflictStrategy: NameConflictStrategy,
callback: ImageOpCallback,
) {
@ -120,6 +123,8 @@ abstract class ImageProvider {
sourceEntry = entry,
targetDir = targetDir,
targetDirDocFile = targetDirDocFile,
width = width,
height = height,
nameConflictStrategy = nameConflictStrategy,
exportMimeType = exportMimeType,
)
@ -138,6 +143,8 @@ abstract class ImageProvider {
sourceEntry: AvesEntry,
targetDir: String,
targetDirDocFile: DocumentFileCompat,
width: Int,
height: Int,
nameConflictStrategy: NameConflictStrategy,
exportMimeType: String,
): FieldMap {
@ -178,6 +185,8 @@ abstract class ImageProvider {
MultiTrackImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.TIFF) {
TiffImage(activity, sourceUri, pageId)
} else if (sourceMimeType == MimeTypes.SVG) {
SvgImage(activity, sourceUri)
} else {
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
}
@ -192,7 +201,7 @@ abstract class ImageProvider {
.asBitmap()
.apply(glideOptions)
.load(model)
.submit()
.submit(width, height)
try {
var bitmap = target.get()
if (MimeTypes.needRotationAfterGlide(sourceMimeType)) {

View file

@ -291,6 +291,8 @@
},
"exportEntryDialogFormat": "Format:",
"exportEntryDialogWidth": "Width",
"exportEntryDialogHeight": "Height",
"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 ?}}",
"exportEntryDialogFormat": "Format :",
"exportEntryDialogWidth": "Largeur",
"exportEntryDialogHeight": "Hauteur",
"renameEntryDialogLabel": "Nouveau nom",

View file

@ -179,6 +179,8 @@
"deleteMultiAlbumConfirmationDialogMessage": "{count, plural, other{이 앨범들의 항목 {count}개를 삭제하시겠습니까?}}",
"exportEntryDialogFormat": "형식:",
"exportEntryDialogWidth": "가로",
"exportEntryDialogHeight": "세로",
"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/services.dart';
import 'package:aves/services/media/enums.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart';
@ -87,7 +88,7 @@ abstract class MediaFileService {
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required String mimeType,
required EntryExportOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
});
@ -368,7 +369,7 @@ class PlatformMediaFileService implements MediaFileService {
@override
Stream<ExportOpEvent> export(
Iterable<AvesEntry> entries, {
required String mimeType,
required EntryExportOptions options,
required String destinationAlbum,
required NameConflictStrategy nameConflictStrategy,
}) {
@ -377,7 +378,9 @@ class PlatformMediaFileService implements MediaFileService {
.receiveBroadcastStream(<String, dynamic>{
'op': 'export',
'entries': entries.map(_toPlatformEntryMap).toList(),
'mimeType': mimeType,
'mimeType': options.mimeType,
'width': options.width,
'height': options.height,
'destinationPath': destinationAlbum,
'nameConflictStrategy': nameConflictStrategy.toPlatform(),
})
@ -434,3 +437,18 @@ class PlatformMediaFileService implements MediaFileService {
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
nodeSize: nodeSize,
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/ref/mime_types.dart';
import 'package:aves/services/media/media_file_service.dart';
import 'package:aves/utils/mime_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:flutter/material.dart';
@ -19,6 +20,8 @@ class ExportEntryDialog extends StatefulWidget {
}
class _ExportEntryDialogState extends State<ExportEntryDialog> {
final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController();
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
String _mimeType = MimeTypes.jpeg;
AvesEntry get entry => widget.entry;
@ -30,13 +33,33 @@ class _ExportEntryDialogState extends State<ExportEntryDialog> {
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
Widget build(BuildContext context) {
final l10n = context.l10n;
return AvesDialog(
content: Row(
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.exportEntryDialogFormat),
Text(l10n.exportEntryDialogFormat),
const SizedBox(width: AvesDialog.controlCaptionPadding),
DropdownButton<String>(
items: imageExportFormats.map((mimeType) {
@ -54,16 +77,73 @@ class _ExportEntryDialogState extends State<ExportEntryDialog> {
),
],
),
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();
},
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(MaterialLocalizations.of(context).cancelButtonLabel),
),
TextButton(
onPressed: () => Navigator.pop(context, _mimeType),
child: Text(context.l10n.applyButtonLabel),
ValueListenableBuilder<bool>(
valueListenable: _isValidNotifier,
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/services.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/widgets/collection/collection_page.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;
final mimeType = await showDialog<String>(
final options = await showDialog<EntryExportOptions>(
context: context,
builder: (context) => ExportEntryDialog(entry: entry),
);
if (mimeType == null) return;
if (options == null) return;
final selection = <AvesEntry>{};
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)
opStream: mediaFileService.export(
selection,
mimeType: mimeType,
options: options,
destinationAlbum: destinationAlbum,
nameConflictStrategy: NameConflictStrategy.rename,
),

View file

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