paper_canvas
A multi-page handwriting and drawing canvas for Flutter, with configurable paper sizes, vector paper templates, an unbounded canvas mode, shape tools, and true vector PDF export and printing.
Credit.
paper_canvasis a derivative work ofscribe_canvasby SKS-0212, used and modified under the MIT License. The drawing engine at its core — picture-cached rendering, the variable-width ink renderer, the object eraser, and the vector PDF approach — is their work. See NOTICE.md for a full breakdown of what was inherited and what was added here.
Why this exists
Most Flutter drawing packages hand you an infinite white sheet and a PNG at the end of it. This one is built for notes and documents: real paper sizes, real ruling, and a PDF you can actually print.
- Vector, not pixels. Strokes are re-emitted as PDF paths through
PdfGraphics, so a page is roughly 12 KB and stays sharp at any zoom or print size. Not a screenshot wrapped in a PDF. - The screen and the page agree. Paper templates are defined once and
replayed into both the Flutter
Canvasand the PDF, so what prints is what you drew. - O(1) rendering. Completed strokes are baked into a
ui.Picture; only the active stroke is processed per frame, so a page with thousands of strokes still draws at 60fps.
Contents
- Install · Quick start
- Paper sizes · Templates · Canvas modes
- Tools · Pages · Navigation
- Backgrounds, headers & footers
- PDF export & printing · Persistence · Thumbnails
- Full API reference · Recipes
Install
dependencies:
paper_canvas: ^0.2.2
Requires Flutter 3.27 / Dart 3.6 or newer. Supports Android, iOS, macOS, Windows and Linux.
Quick start
import 'package:paper_canvas/paper_canvas.dart';
final controller = PaperCanvasController();
PaperCanvas(
controller: controller,
tool: PaperTool.brush,
color: Colors.black,
strokeWidth: 4,
pageFormat: const PageFormat(size: PaperSize.a4),
canvasMode: CanvasMode.paged,
template: PaperTemplate.cornell,
templateTheme: PaperTemplateTheme.light,
);
A complete runnable app — toolbar, page setup sheet, export and print — is in
example/.
Paper sizes
const PageFormat(size: PaperSize.a3, orientation: PageOrientation.landscape)
PaperSize |
Portrait (pt) |
|---|---|
a3 |
841.89 × 1190.55 |
a4 |
595.28 × 841.89 |
a5 |
419.53 × 595.28 |
letter |
612 × 792 |
legal |
612 × 1008 |
PageOrientation.portrait or .landscape. Width and height are already
swapped for landscape, so you never handle orientation yourself.
Dimensions are in PostScript points — the same unit the pdf package uses
— so on-screen geometry and exported geometry are the same numbers, with no
conversion step to get wrong.
format.width // 841.89
format.height // 1190.55
format.pageSize // Size
format.pdfPageFormat // PdfPageFormat, for the pdf package
format.isLandscape
format.label // "A3 Landscape"
format.copyWith(size: PaperSize.a4)
// Recover a format from stored dimensions (null if they match no sheet)
PageFormat.fromDimensions(595.28, 841.89);
// Round-trip through JSON
format.toJson();
PageFormat.fromJson(map);
Paper templates
All ruling is drawn as vectors, so it stays crisp at any zoom and prints identically to what is on screen.
PaperTemplate |
Description |
|---|---|
blank |
No ruling. |
lined |
Ruled baselines with a left margin rule. |
grid |
Squared paper, 5 mm by default. |
dots |
Dot grid, 5 mm by default. |
cornell |
Cornell notes: title strip, cue column, notes area, summary band. |
Tune them with PaperTemplateTheme:
const PaperTemplateTheme(
lineColor: Color(0xFFB9C6D6), // baselines, grid lines, dots
accentColor: Color(0xFFE8A0A0), // margin rules, Cornell dividers
pageColor: Color(0xFFFFFFFF), // the paper itself
lineSpacing: 24.0, // points; ~8mm ruling
gridSpacing: 14.17, // points; 5mm
lineWidth: 0.6,
)
PaperTemplateTheme.light and PaperTemplateTheme.dark are provided; use
copyWith to adjust one value.
To change just the paper colour, use canvasColor rather than building a whole
theme. It composes with the template theme, so the ruling is preserved:
PaperCanvas(
canvasColor: const Color(0xFFFFF8E1), // cream paper, ruling unchanged
)
It applies everywhere paper is drawn — the canvas, the exported PDF and
thumbnails. Left null (the default) the theme's own pageColor is used, which
is white. Note this is the paper, not the app background: an app that themes
its chrome for dark mode usually still wants white paper, since the drawing is
a document that gets printed.
Templates can also be rendered directly, outside a canvas:
// into any Flutter Canvas
PaperTemplateRenderer.paintToCanvas(
canvas: canvas, origin: Offset.zero,
template: PaperTemplate.lined, pageSize: format.pageSize,
theme: PaperTemplateTheme.light,
);
// as a pw.Widget for the pdf package
PaperTemplateRenderer.pdfWidget(
template: PaperTemplate.grid, pageSize: format.pageSize,
theme: PaperTemplateTheme.light,
);
// or rasterised
final ui.Image image = await renderTemplateImage(
template: PaperTemplate.dots, pageSize: format.pageSize,
theme: PaperTemplateTheme.light, pixelRatio: 2,
);
Implement TemplateSink to render the ruling somewhere else entirely.
Canvas modes
CanvasMode.paged // a stack of fixed-size pages
CanvasMode.infinite // one unbounded 2D surface
paged— pages laid out top to bottom. A stroke's page is derived from its y coordinate; strokes are clamped to the page width. Export produces one PDF page per canvas page, growing to fit any ink past the last known page.infinite— no page bands, no clamping, and the surface grows outward as you approach its edge. Export crops to the drawn region and fits it onto a single sheet, never scaling above 1:1, so a small sketch prints at its true size instead of being blown up.
Switching mode or paper size at runtime re-derives page count and geometry, so you can offer it as a live setting.
The unbounded surface starts at infiniteCanvasMinSize (default
Size(10000, 15000)) and grows to keep infiniteCanvasMargin (default 2000)
of slack beyond the drawn content:
PaperCanvas(
canvasMode: CanvasMode.infinite,
infiniteCanvasMinSize: const Size(20000, 20000),
infiniteCanvasMargin: 4000,
)
Tools
PaperCanvas(tool: PaperTool.brush, ...)
PaperTool |
Behaviour |
|---|---|
pen |
Freehand at a constant width. |
brush |
Freehand whose width tracks drawing speed, giving an ink-like taper. |
line |
Straight line between two anchors. |
rectangle |
Axis-aligned rectangle. |
circle |
Ellipse inscribed in the drag bounds. |
eraser |
Object eraser — removes whole strokes it touches, rather than painting over them. |
Shapes store exactly two anchor points and derive their outline on demand, so a rectangle stays a rectangle through save and reload rather than degrading into a polyline that happens to look rectangular.
PaperTool.isShape and .isVariableWidth are available for building toolbars.
Colour and width are host-driven via color, strokeWidth and eraserWidth,
and changes apply to subsequent strokes.
Page management
controller.addPage(); // append
controller.insertPage(1); // insert, shifting later strokes down
controller.deletePage(1); // delete, shifting later strokes up
controller.pageCount;
Undo/redo works both globally and per page:
controller.undo();
controller.redo();
controller.undoPage(0);
controller.redoPage(0);
controller.canUndo;
controller.canRedo;
controller.clear();
Navigation
- Pinch to zoom, drag to pan;
isPanMode: truedisables drawing so a single finger pans instead. ScrollMode.continuous(default) scrolls freely;ScrollMode.discretesnaps page by page.initialPageIndexsets the starting page.controller.resetView()returns to the default zoom and position.- Palm rejection is applied to touch input while a stylus is in use.
Backgrounds, headers and footers
Per-page background images, and header/footer images repeated on every exported page:
await controller.setBackgroundImage(0, bytes, clearOthers: true);
await controller.setNetworkBackgroundImage(0, 'https://…/bg.png');
controller.clearBackgrounds();
await controller.setHeaderImage(bytes);
await controller.setNetworkHeaderImage('https://…/logo.png');
await controller.setFooterImage(bytes);
await controller.setNetworkFooterImage('https://…/footer.png');
Setting a background for page 0 only makes it repeat on every page.
PDF export and printing
// Share sheet
await controller.exportToPdf(fileName: 'notes.pdf');
// Platform print dialog (AirPrint on iOS, print framework on Android)
await controller.printPdf(documentName: 'Notes');
// Raw bytes, no UI — for upload, caching or preview
final Uint8List? bytes = await controller.buildPdf();
Output is vector: variable-width ink is emitted as filled polygon
envelopes, shapes as constant-width outlines, and templates as stroked paths.
Page size follows pageFormat, and templates, backgrounds, headers and footers
are all composited in.
Returns null when nothing has been drawn.
Persistence
Structured JSON — one object per stroke, ideal when the destination stores real JSON:
final List<Map<String, dynamic>> strokes = controller.getStrokesJson();
controller.loadStrokesJson(strokes);
Or the compact string codec (delta-encoded, much smaller):
final String data = controller.getEncodedData();
controller.loadEncodedData(data);
Malformed entries are skipped rather than aborting the load, so one bad stroke cannot cost the user the rest of the drawing.
Stroke is exported, so you can inspect or transform strokes yourself —
points, widths, color, strokeWidth, tool, bounds, outlinePoints,
isPointNear(), simplify() (Ramer–Douglas–Peucker), plus toJson/fromJson
and serialize/deserialize.
Thumbnails
final ui.Image? image = await controller.renderThumbnail(
maxDimension: 512,
padding: 24,
);
Renders the content bounding box with its paper template, not the whole surface — on an infinite canvas the drawing is usually a small corner of a very large document, so rasterising all of it would be slow and mostly blank.
controller.hasStrokes; // is anything drawn
controller.contentBounds; // Rect? of the committed ink
API reference
PaperCanvas
| Property | Type | Default | Purpose |
|---|---|---|---|
controller |
PaperCanvasController? |
— | Imperative control. |
tool |
PaperTool? |
null |
Active tool; falls back to isEraser. |
color |
Color |
black |
Pen colour for subsequent strokes. |
strokeWidth |
double |
4.0 |
Pen width. |
eraserWidth |
double |
30.0 |
Eraser hit radius. |
pageFormat |
PageFormat |
A4 portrait | Paper size and orientation. |
canvasMode |
CanvasMode |
paged |
Paged or unbounded. |
infiniteCanvasMinSize |
Size |
10000x15000 |
Minimum unbounded surface. |
infiniteCanvasMargin |
double |
2000 |
Slack kept beyond the content. |
template |
PaperTemplate |
blank |
Paper ruling. |
templateTheme |
PaperTemplateTheme |
light |
Ruling colours and metrics. |
canvasColor |
Color? |
null → white |
Paper colour; overrides the theme's pageColor. |
multiPage |
bool |
true |
Allow more than one page. |
isPanMode |
bool |
false |
Disable drawing, pan only. |
isEraser |
bool |
false |
Legacy eraser toggle; prefer tool. |
scrollMode |
ScrollMode |
continuous |
Free or page-snapped scrolling. |
initialPageIndex |
int |
0 |
Starting page. |
strokeSizes |
List<double> |
[2,4,8,16,32] |
Sizes the built-in header cycles. |
eraserSizes |
List<double> |
[10,20,30,60,100] |
Eraser sizes it cycles. |
colors |
List<Color> |
6 colours | Built-in palette. |
initialColor |
Color |
black |
Palette starting colour. |
eraserIcon, eraserIconSize, eraserActiveColor, eraserInactiveColor |
— | — | Built-in header styling. |
Callbacks: onStrokeStart, onStrokeEnd, onUndo, onRedo,
onColorChanged, onStrokeWidthChanged, onEraserWidthChanged,
onToggleEraser.
PaperCanvasController
| Member | Purpose |
|---|---|
clear() |
Remove all strokes and history. |
undo() / redo() |
Global undo/redo. |
undoPage(i) / redoPage(i) |
Per-page undo/redo. |
canUndo / canRedo |
Availability. |
addPage() / insertPage(i) / deletePage(i) |
Page management. |
pageCount |
Number of pages (always 1 when infinite). |
hasStrokes |
Whether anything is drawn. |
contentBounds |
Rect? of committed ink. |
resetView() |
Reset zoom and pan. |
exportToPdf() / printPdf() / buildPdf() |
Export and print. |
getStrokesJson() / loadStrokesJson() |
Structured JSON. |
getEncodedData() / loadEncodedData() |
Compact string codec. |
renderThumbnail() |
Rasterise the drawn area. |
setBackgroundImage() / setNetworkBackgroundImage() / clearBackgrounds() |
Page backgrounds. |
setHeaderImage() / setFooterImage() (+ network variants) |
Export headers/footers. |
Dispose it with controller.dispose().
Recipes
Host-driven toolbar
PaperCanvas renders its own per-page header with colour, stroke-width and
eraser controls. If your app has its own toolbar, mirror the callbacks back so
the two never disagree:
PaperCanvas(
controller: controller,
tool: _tool,
color: _color,
strokeWidth: _width,
onColorChanged: (c) => setState(() => _color = c),
onStrokeWidthChanged: (w) => setState(() => _width = w),
onEraserWidthChanged: (w) => setState(() => _eraserWidth = w),
onToggleEraser: (e) => setState(() =>
_tool = e ? PaperTool.eraser : _lastDrawTool),
onStrokeEnd: () => setState(() {}), // refresh undo/redo enablement
)
Saving a document with its page settings
PageFormat serialises to JSON, so page setup can travel with the strokes:
final doc = {
'format': format.toJson(),
'mode': mode.name,
'template': template.name,
'strokes': controller.getStrokesJson(),
};
Uploading a PDF instead of sharing it
final bytes = await controller.buildPdf();
if (bytes != null) await api.upload(bytes);
Tests
flutter test
Covers vector PDF export (page formats, page counts, templates, shapes, infinite-canvas fitting), runtime page-geometry changes, and host-driven colour.
License
MIT — see LICENSE. Derived from scribe_canvas by SKS-0212, whose
copyright is retained; see NOTICE.md.