Skip to content

Commit 3fe46b2

Browse files
committed
Release 5.3.1
1 parent ec01ca0 commit 3fe46b2

15 files changed

+1197
-531
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 5.3.1 - 26 Jan 2026
2+
3+
- Fixes an Android crash when adding image annotations with the `annotationsCreated` event listener active. (#50400)
4+
- Fixes a type cast error when parsing `GoToAction` link annotations with integer parameters.
5+
16
## 5.3.0 — 22 Jan 2026
27

38
- Adds a headless document API for opening and manipulating PDF documents without displaying a viewer. (J#HYB-931)

android/src/main/java/com/pspdfkit/flutter/pspdfkit/document/FlutterPdfDocument.kt

Lines changed: 138 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -366,12 +366,75 @@ class FlutterPdfDocument(
366366
attachment: Any?,
367367
callback: (Result<Boolean?>) -> Unit
368368
) {
369+
// WORKAROUND for PSPDFKit Native SDK limitation:
370+
//
371+
// Problem: When creating image annotations, PSPDFKit fires `onAnnotationCreated`
372+
// synchronously during `createAnnotationFromInstantJsonAsync()`, BEFORE we can attach
373+
// the binary image data. When event listeners call `toInstantJson()` on the annotation,
374+
// it fails with: "Can't create Instant JSON for stamp annotation that has no content -
375+
// title, stamp icon or an image!"
376+
//
377+
// Root cause: PSPDFKit stores image annotations as StampAnnotations internally.
378+
// The `toInstantJson()` method validates that stamps have "content" (title, stampType,
379+
// or attached image). Since the image isn't attached yet when the event fires, and
380+
// image annotations don't have title/stampType by spec, validation fails.
381+
//
382+
// Workaround: Inject a temporary title into the JSON before creating the annotation.
383+
// This satisfies the validation check. Once the image is attached (after creation),
384+
// subsequent serialization includes the image data.
385+
//
386+
// Note: The Instant JSON spec does NOT define `title` for image annotations
387+
// (only for stamps). This is purely an internal workaround for PSPDFKit's validation.
388+
//
389+
// Ideal fix: PSPDFKit should either:
390+
// 1. Allow attaching binary data before/during annotation creation, or
391+
// 2. Skip validation in `toInstantJson()` for annotations being created, or
392+
// 3. Delay firing `onAnnotationCreated` until after attachments are set
393+
val processedJson = if (attachment != null && attachment is String) {
394+
try {
395+
val json = JSONObject(jsonAnnotation)
396+
val annotationType = json.optString("type", "")
397+
// Inject placeholder title for image/stamp annotations with binary attachments.
398+
// "Image" for pspdfkit/image, "Stamp" for pspdfkit/stamp (per spec naming).
399+
when (annotationType) {
400+
"pspdfkit/image" -> {
401+
if (!json.has("title") || json.optString("title").isNullOrEmpty()) {
402+
json.put("title", "Image")
403+
}
404+
}
405+
"pspdfkit/stamp" -> {
406+
if (!json.has("title") || json.optString("title").isNullOrEmpty()) {
407+
json.put("title", "Stamp")
408+
}
409+
}
410+
}
411+
json.toString()
412+
} catch (e: Exception) {
413+
jsonAnnotation // Use original if parsing fails
414+
}
415+
} else {
416+
jsonAnnotation
417+
}
418+
369419
disposable =
370-
pdfDocument.annotationProvider.createAnnotationFromInstantJsonAsync(jsonAnnotation)
420+
pdfDocument.annotationProvider.createAnnotationFromInstantJsonAsync(processedJson)
371421
.subscribeOn(Schedulers.computation())
372422
.observeOn(AndroidSchedulers.mainThread())
373423
.subscribe(
374424
{ annotation ->
425+
// For stamp/image annotations, set title IMMEDIATELY after creation.
426+
// This is needed because PSPDFKit fires onAnnotationCreated synchronously
427+
// during createAnnotationFromInstantJsonAsync(), and the title from JSON
428+
// may not be applied for image annotations (title is not in the image spec).
429+
if (annotation is com.pspdfkit.annotations.StampAnnotation) {
430+
if (annotation.title.isNullOrEmpty()) {
431+
val type = try {
432+
JSONObject(processedJson).optString("type", "")
433+
} catch (e: Exception) { "" }
434+
annotation.title = if (type == "pspdfkit/image") "Image" else "Stamp"
435+
}
436+
}
437+
375438
// Handle attachment if provided
376439
if (attachment != null && attachment is String) {
377440
try {
@@ -383,6 +446,23 @@ class FlutterPdfDocument(
383446
val binaryData = Base64.decode(binary, Base64.DEFAULT)
384447
val dataProvider = BinaryDataProvider(binaryData)
385448
annotation.attachBinaryInstantJsonAttachment(dataProvider, contentType)
449+
450+
// Generate the appearance stream to render the attached image.
451+
annotation.generateAppearanceStreamAsync()
452+
.subscribeOn(Schedulers.computation())
453+
.observeOn(AndroidSchedulers.mainThread())
454+
.subscribe(
455+
{
456+
callback(Result.success(true))
457+
},
458+
{ appearanceError ->
459+
// Log but don't fail - the annotation was created successfully,
460+
// just the appearance generation failed
461+
android.util.Log.w("FlutterPdfDocument", "Failed to generate appearance stream: ${appearanceError.message}")
462+
callback(Result.success(true))
463+
}
464+
)
465+
return@subscribe
386466
}
387467
} catch (e: Exception) {
388468
// Log but don't fail - annotation was created successfully
@@ -479,48 +559,72 @@ class FlutterPdfDocument(
479559
}
480560

481561
override fun getAnnotationsJson(pageIndex: Long, type: String, callback: (Result<String>) -> Unit) {
482-
val jsonArray = JSONArray()
562+
try {
563+
val jsonArray = JSONArray()
483564

484-
// Get annotations directly from the annotation provider
485-
val annotations = pdfDocument.annotationProvider.getAnnotations(pageIndex.toInt())
486-
val annotationTypeSet = AnnotationTypeAdapter.fromString(type)
487-
val filterAll = annotationTypeSet.size == com.pspdfkit.annotations.AnnotationType.values().size
565+
// Get annotations directly from the annotation provider
566+
val annotations = pdfDocument.annotationProvider.getAnnotations(pageIndex.toInt())
567+
val annotationTypeSet = AnnotationTypeAdapter.fromString(type)
568+
val filterAll = annotationTypeSet.size == com.pspdfkit.annotations.AnnotationType.values().size
488569

489-
for (annotation in annotations) {
490-
// Filter by type if not "all"
491-
if (!filterAll && !annotationTypeSet.contains(annotation.type)) {
492-
continue
493-
}
570+
for (annotation in annotations) {
571+
// Filter by type if not "all"
572+
if (!filterAll && !annotationTypeSet.contains(annotation.type)) {
573+
continue
574+
}
494575

495-
// Skip floating/unattached annotations that can't be serialized to JSON.
496-
// These are typically internal annotations (like some LINK annotations) that
497-
// haven't been fully connected to the document structure.
498-
if (!annotation.isAttached) {
499-
continue
500-
}
576+
// Skip floating/unattached annotations that can't be serialized to JSON.
577+
// These are typically internal annotations (like some LINK annotations) that
578+
// haven't been fully connected to the document structure.
579+
if (!annotation.isAttached) {
580+
continue
581+
}
501582

502-
var jsonString = annotation.toInstantJson()
583+
// For stamp/image annotations that don't have content yet,
584+
// set a placeholder title so toInstantJson() can succeed.
585+
// Don't check hasBinaryInstantJsonAttachment() because it may return true
586+
// before actual data is attached (it checks imageAttachmentId, not binary data).
587+
if (annotation is com.pspdfkit.annotations.StampAnnotation) {
588+
if (annotation.title.isNullOrEmpty() && annotation.stampType == null) {
589+
annotation.title = "Image"
590+
}
591+
}
503592

504-
// Skip annotations that can't be serialized to JSON
505-
if (jsonString.isNullOrEmpty()) {
506-
continue
507-
}
593+
// Try to convert annotation to JSON, skip if it fails
594+
// Some annotations (like stamp/image annotations) may throw IllegalStateException
595+
// if they don't have their content fully set yet
596+
var jsonString: String
597+
try {
598+
jsonString = annotation.toInstantJson() ?: continue
599+
} catch (e: IllegalStateException) {
600+
// Skip annotations that can't be serialized
601+
continue
602+
}
508603

509-
// For annotations with binary attachments, include the attachment data
510-
if (annotation.hasBinaryInstantJsonAttachment()) {
511-
jsonString = addAttachmentToJson(annotation, jsonString)
512-
}
604+
// Skip annotations that can't be serialized to JSON
605+
if (jsonString.isEmpty()) {
606+
continue
607+
}
513608

514-
// Try to parse the JSON, skip if it fails
515-
try {
516-
jsonArray.put(JSONObject(jsonString))
517-
} catch (e: Exception) {
518-
android.util.Log.w("FlutterPdfDocument", "Failed to parse annotation JSON: ${e.message}")
519-
continue
609+
// For annotations with binary attachments, include the attachment data
610+
if (annotation.hasBinaryInstantJsonAttachment()) {
611+
jsonString = addAttachmentToJson(annotation, jsonString)
612+
}
613+
614+
// Try to parse the JSON, skip if it fails
615+
try {
616+
jsonArray.put(JSONObject(jsonString))
617+
} catch (e: Exception) {
618+
android.util.Log.w("FlutterPdfDocument", "Failed to parse annotation JSON: ${e.message}")
619+
continue
620+
}
520621
}
521-
}
522622

523-
callback(Result.success(jsonArray.toString()))
623+
callback(Result.success(jsonArray.toString()))
624+
} catch (e: Exception) {
625+
android.util.Log.e("FlutterPdfDocument", "Error getting annotations JSON: ${e.message}", e)
626+
callback(Result.failure(e))
627+
}
524628
}
525629

526630
/**

android/src/main/java/com/pspdfkit/flutter/pspdfkit/events/FlutterEventsHelper.kt

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,52 @@ class FlutterEventsHelper(
4141

4242
when (event) {
4343
NutrientEvent.ANNOTATIONS_CREATED -> {
44-
createAnnotationListener(pdfFragment, event,
44+
createAnnotationListener(pdfFragment, event,
4545
onCreated = { annotation ->
46-
sendEvent(event, mapOf("annotations" to listOf(annotation.toInstantJson())))
46+
try {
47+
// For stamp/image annotations that don't have content yet,
48+
// set a placeholder title so toInstantJson() can succeed.
49+
// This handles the timing issue where onAnnotationCreated fires
50+
// before the binary attachment is set.
51+
//
52+
// Note: hasBinaryInstantJsonAttachment() may return true even when
53+
// the actual binary data hasn't been attached yet (it checks if
54+
// imageAttachmentId is set in JSON, not if data is present).
55+
// So we only check for title and stampType.
56+
if (annotation is com.pspdfkit.annotations.StampAnnotation) {
57+
if (annotation.title.isNullOrEmpty() && annotation.stampType == null) {
58+
annotation.title = "Image"
59+
}
60+
}
61+
sendEvent(event, mapOf("annotations" to listOf(annotation.toInstantJson())))
62+
} catch (e: IllegalStateException) {
63+
// Some annotations (like stamp/image annotations) may not have their
64+
// content fully set when onAnnotationCreated is called. In this case,
65+
// toInstantJson() will throw. We skip the event - the annotation
66+
// will trigger an onAnnotationUpdated event once the content is set.
67+
Log.d("FlutterEventsHelper", "Skipping annotation created event - annotation not fully initialized: ${e.message}")
68+
}
4769
}
4870
)
4971
}
5072
NutrientEvent.ANNOTATIONS_UPDATED -> {
5173
createAnnotationListener(pdfFragment, event,
5274
onUpdated = { annotation ->
53-
sendEvent(event, mapOf("annotations" to listOf(annotation.toInstantJson())))
75+
try {
76+
// For stamp/image annotations that don't have content yet,
77+
// set a placeholder title so toInstantJson() can succeed.
78+
// Same logic as ANNOTATIONS_CREATED - don't check hasBinaryInstantJsonAttachment()
79+
// because it may return true before actual data is attached.
80+
if (annotation is com.pspdfkit.annotations.StampAnnotation) {
81+
if (annotation.title.isNullOrEmpty() && annotation.stampType == null) {
82+
annotation.title = "Image"
83+
}
84+
}
85+
sendEvent(event, mapOf("annotations" to listOf(annotation.toInstantJson())))
86+
} catch (e: IllegalStateException) {
87+
// Some annotations may not be fully initialized yet
88+
Log.d("FlutterEventsHelper", "Skipping annotation updated event - annotation not fully initialized: ${e.message}")
89+
}
5490
}
5591
)
5692
}

example/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: nutrient_example
22
description: Demonstrates how to use the Nutrient Flutter plugin.
3-
version: 5.3.0
3+
version: 5.3.1
44
homepage: https://nutrient.io/
55
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
66
environment:

ios/nutrient_flutter.podspec

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
#
66
Pod::Spec.new do |s|
77
s.name = "nutrient_flutter"
8-
s.version = "5.3.0"
8+
s.version = "5.3.1"
99
s.homepage = "https://nutrient.io"
1010
s.documentation_url = "https://nutrient.io/guides/flutter"
1111
s.license = { type: "Commercial", file: "../LICENSE" }
@@ -22,6 +22,6 @@ Pod::Spec.new do |s|
2222
s.dependency("Instant", "26.4.0")
2323
s.swift_version = "5.0"
2424
s.platform = :ios, "16.0"
25-
s.version = "5.3.0"
25+
s.version = "5.3.1"
2626
s.pod_target_xcconfig = { "DEFINES_MODULE" => "YES", "SWIFT_INSTALL_OBJC_HEADER" => "NO" }
2727
end

lib/src/annotations/annotation_actions.dart

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ class GoToAction extends Action {
5252
return GoToAction(
5353
pageIndex: json['pageIndex'] as int,
5454
destinationType: json['destinationType'] as String,
55-
params:
56-
(json['params'] as List<dynamic>?)?.map((e) => e as double).toList(),
55+
params: (json['params'] as List<dynamic>?)
56+
?.map((e) => (e as num).toDouble())
57+
.toList(),
5758
subAction: json['subAction'] != null
5859
? Action.fromJson(json['subAction'] as Map<String, dynamic>)
5960
: null,

lib/src/annotations/annotation_models.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,8 @@ class NoteAnnotation extends Annotation {
365365
factory NoteAnnotation.fromJson(Map<String, dynamic> json) {
366366
return NoteAnnotation(
367367
id: json['id'] as String?,
368-
text: TextContent.fromJson(Map<String, dynamic>.from(json['text'] as Map)),
368+
text:
369+
TextContent.fromJson(Map<String, dynamic>.from(json['text'] as Map)),
369370
icon: NoteIcon.values.firstWhere(
370371
(e) => e.toString().split('.').last == json['icon'],
371372
orElse: () => NoteIcon.note,
@@ -801,7 +802,8 @@ class LineAnnotation extends ShapeAnnotation {
801802
startPoint: Annotation._toDoubleList(json['startPoint'] as List<dynamic>),
802803
endPoint: Annotation._toDoubleList(json['endPoint'] as List<dynamic>),
803804
lineCaps: json['lineCaps'] != null
804-
? LineCaps.fromJson(Map<String, dynamic>.from(json['lineCaps'] as Map))
805+
? LineCaps.fromJson(
806+
Map<String, dynamic>.from(json['lineCaps'] as Map))
805807
: null,
806808
fillColor: Annotation._hexToColor(json['fillColor'] as String?),
807809
borderStyle: json['borderStyle'] != null
@@ -967,7 +969,8 @@ class FreeTextAnnotation extends Annotation {
967969
bbox: Annotation._toDoubleList(json['bbox'] as List),
968970
createdAt: json['createdAt'] as String,
969971
creatorName: json['creatorName'] as String?,
970-
text: TextContent.fromJson(Map<String, dynamic>.from(json['text'] as Map)),
972+
text:
973+
TextContent.fromJson(Map<String, dynamic>.from(json['text'] as Map)),
971974
backgroundColor:
972975
Annotation._hexToColor(json['backgroundColor'] as String?),
973976
fontSize: Annotation._toDouble(json['fontSize']),

lib/src/annotations/annotation_utils.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ class AnnotationUtils {
1313
final rawAttachments = json['attachments'] as Map;
1414
attachments = <String, Map<String, dynamic>>{};
1515
rawAttachments.forEach((key, value) {
16-
attachments![key.toString()] = Map<String, dynamic>.from(value as Map);
16+
attachments![key.toString()] =
17+
Map<String, dynamic>.from(value as Map);
1718
});
1819
}
1920

0 commit comments

Comments
 (0)