photo_markup 1.0.2
photo_markup: ^1.0.2 copied to clipboard
Draw boxes, arrows, freehand strokes and text over a photo, read it back as resolution-independent vector data, and flatten it to a JPEG or PNG.
photo_markup #
Reusable, app-agnostic image markup and annotation for Flutter.
Draw boxes, ellipses, arrows, freehand strokes and text over a photo, read it back as resolution-independent vector data, and flatten it to a JPEG or PNG.

The package does no file I/O, no persistence, and no network calls. It takes an
ImageProvider plus annotations in and hands a MarkupResult back out — where
those bytes go is entirely up to you.
Why normalized coordinates #
Every coordinate is stored as a fraction of the image's intrinsic size
(0.0..1.0), and stroke widths and font sizes are fractions of the image's
shorter side. Nothing is ever stored in device pixels.
That single decision is what makes the same MarkupDocument render correctly
in a 48 px thumbnail, in a full-screen editor on a tablet, and in a 4000 px
flattened export — with line weights that scale instead of turning into
hairlines or slabs. The editor, the viewer and the flattener all share one
CustomPainter, so all three produce identical geometry.
Features #
| Tools | Box, ellipse, arrow, freehand, text |
| Editor | Pinch-zoom and pan, one-finger drawing while zoomed, double-tap to reset, undo/redo, color and stroke-width pickers |
| Viewer | Read-only overlay with no gesture layer |
| Export | JPEG or PNG, optional width cap, never upscales, original never modified |
| Storage | Forward-tolerant JSON codec — you own persistence |
| i18n | Every string overridable via PhotoMarkupStrings |
| Dependencies | package:image only, for JPEG encoding. No platform channels |
Install #
dependencies:
photo_markup: ^1.0.2
Quick start #
Push the editor and await its result:
import 'package:photo_markup/photo_markup.dart';
final result = await Navigator.of(context).push<MarkupResult>(
MaterialPageRoute(
builder: (_) => PhotoMarkupEditor(image: FileImage(photo)),
),
);
if (result != null) {
// The vector data — this is your source of truth.
final json = MarkupCodec.encodeJson(result.document);
// The baked photo+markup image — a derived artifact.
final Uint8List? bytes = result.flattenedImage;
}
result is null when the user cancels.
Displaying markup read-only #
PhotoMarkupViewer(
image: FileImage(photo),
document: MarkupCodec.decodeJson(json),
)
The viewer lays the photo out at document.imageAspectRatio and paints the
overlay across exactly the same box, so markup can never misregister. There is
deliberately no fit parameter: a fit that letterboxes or crops would move the
photo without moving the annotations. To scale or crop the pair together, wrap
the viewer — FittedBox transforms the photo and its markup as one.
Persistence #
MarkupCodec converts a document to and from a compact JSON shape. Store the
string wherever you like — a database column, a file, an API payload:
{
"v": 1,
"aspect": 1.3333,
"items": [
{
"id": "m3k2p-0",
"type": "box",
"color": 4294198070,
"stroke": 0.006,
"points": [{"x": 0.21, "y": 0.34}, {"x": 0.58, "y": 0.71}]
}
]
}
Decoding is deliberately forgiving, so an older build never chokes on data written by a newer one:
- a higher
vis accepted; - unrecognized
typevalues are kept aside rather than thrown on; - annotations with no points are dropped;
- out-of-range points are clamped to
0..1; - a missing or invalid
aspectfalls back tofallbackAspectRatio; - a non-object root yields an empty document.
final doc = MarkupCodec.decodeJson(json, fallbackAspectRatio: 4 / 3);
Tolerance extends to writing as well: decoding keeps anything it does not understand and encoding puts it back where it was, so an older build can open and re-save a newer document without destroying data.
| Unrecognized input | Kept in | Re-emitted as |
|---|---|---|
| A field inside an annotation | Annotation.extras |
the same key on that annotation |
| A top-level field | MarkupDocument.extras |
the same top-level key |
An items entry of an unknown type |
MarkupDocument.unsupportedItems |
the same entry, at its original index |
Fields this version owns always win on a collision, and v is always rewritten
to the version that did the writing. Unsupported items are never painted.
One gap to be aware of: PhotoMarkupEditor takes initialAnnotations, not a
whole document, so a round trip through the editor preserves each
annotation's extras but not document-level passthrough. Encode from the
document you decoded if you need that.
Flattening #
PhotoMarkupEditor flattens on save by default. To do it yourself:
final bytes = await MarkupFlattener.flatten(
image: decodedUiImage,
document: doc,
format: MarkupImageFormat.jpeg,
jpegQuality: 90,
targetWidth: 2000, // caps output width; never upscales
);
Annotations are drawn at the output resolution, so they stay vector-sharp regardless of how the photo was sampled. The source image is never modified.
Output is capped at MarkupFlattener.defaultTargetWidth (2048 px) unless you
pass a targetWidth yourself; pass double.infinity for full source
resolution. The cap matters because flattening allocates an uncompressed RGBA
buffer of width × height × 4 bytes — a 12 MP photo costs ~48 MB per copy, and
JPEG encoding briefly holds two.
JPEG encoding runs on a background isolate, so it does not block the UI thread.
The web has no isolates, so compute runs inline there and a large flatten
will still stall the frame — keep the cap low if web is a target.
Reusing the editor state without the UI #
MarkupEditorController is a plain ChangeNotifier with no widget
dependencies, so you can drive your own chrome or unit-test annotation logic
directly:
final controller = MarkupEditorController(imageAspectRatio: 4 / 3)
..setTool(MarkupTool.arrow)
..beginDraft(const Offset(0.1, 0.1))
..updateDraft(const Offset(0.7, 0.6))
..commitDraft();
controller.undo();
final doc = controller.document;
Drags shorter than 1% of the image are treated as accidental taps and dropped.
Localization #
The package ships English defaults and does not depend on
flutter_localizations — plug in whatever stack you already use:
PhotoMarkupEditor(
image: provider,
strings: PhotoMarkupStrings(
editorTitle: context.l10n.markupTitle,
save: context.l10n.save,
cancel: context.l10n.cancel,
toolBox: context.l10n.box,
),
)
Customizing the palette #
PhotoMarkupEditor(
image: provider,
palette: const [0xFFEF4444, 0xFF3B82F6, 0xFFFFFFFF],
strokeWidths: const [0.003, 0.008, 0.016],
)
Defaults are exported as kDefaultMarkupPalette and kDefaultStrokeWidths.
Platform support #
Android, iOS, macOS, Windows, Linux and web. There are no platform channels; the only native-adjacent work is image decoding, which Flutter handles.
Not supported yet #
Being explicit so you can judge fit before adopting:
- Selecting, moving, resizing or deleting an individual annotation.
MarkupTool.selectis declared but not implemented; the only removal action is Clear all, and text cannot be edited after it is placed. - Off-thread flattening on the web. JPEG encoding moves to an isolate everywhere else, but the web has none, so it runs inline.
- Document-level passthrough through the editor — see Persistence above.
- Unlimited undo. History is capped at 50 steps
(
MarkupEditorController.maxHistory); older steps fall off the bottom. - Layer reordering, opacity, fills, or a redaction/blur tool.
Example #
A runnable demo lives in
example/ — it
exercises the editor, the viewer, the JSON round-trip, and renders the same
document at three sizes to show resolution independence.
cd example
flutter run
License #
BSD-3-Clause. Copyright (c) 2026, Velzosoft. See LICENSE.