Ashlar UI Ashlar UI ashlarui

Chart Export

A chart, or any widget, as a PNG or a one-page PDF, with no package: the bytes are yours to save, share or upload.

Chart Export, as it looks in an app Chart Export, as it looks in an app, dark
import 'package:flutter/widgets.dart';

import 'ui/button.dart';
import 'ui/card.dart';
import 'ui/chart.dart';
import 'ui/chart_export.dart';
import 'save.dart';

typedef _Month = ({String month, double visitors});

const _months = <_Month>[
  (month: 'Jan', visitors: 186),
  (month: 'Feb', visitors: 305),
  (month: 'Mar', visitors: 237),
  (month: 'Apr', visitors: 73),
];

class ChartExportDemo extends StatefulWidget {
  const ChartExportDemo({super.key});

  @override
  State<ChartExportDemo> createState() => _ChartExportDemoState();
}

class _ChartExportDemoState extends State<ChartExportDemo> {
  final _export = ChartExportController();
  String _made = '';

  @override
  Widget build(BuildContext context) => SizedBox(
    width: 460,
    child: Card(
      children: [
        const CardHeader(title: Text('Visitors'), description: Text('January to April')),
        CardContent(
          child: ChartExport(
            controller: _export,
            child: BarChart<_Month>(
              data: _months,
              x: (r) => r.month,
              height: 180,
              children: [
                XAxis(),
                Bar(label: 'Visitors', value: (r) => r.visitors),
              ],
            ),
          ),
        ),
        CardFooter(
          children: [
            Button(
              variant: ButtonVariant.outline,
              onPressed: () async {
                final bytes = await _export.png();
                save('visitors.png', bytes, 'image/png');
                setState(() => _made = 'Saved, ${bytes.length ~/ 1024} KB');
              },
              child: const Text('PNG'),
            ),
            const SizedBox(width: 8),
            Button(
              variant: ButtonVariant.outline,
              onPressed: () async {
                final bytes = await _export.pdf(title: 'Visitors');
                save('visitors.pdf', bytes, 'application/pdf');
                setState(() => _made = 'Saved, ${bytes.length ~/ 1024} KB');
              },
              child: const Text('PDF'),
            ),
            const SizedBox(width: 12),
            Text(_made),
          ],
        ),
      ],
    ),
  );
}

Add it

In your Flutter project:

$ ashlar add chart-export

It writes chart_export.dart into lib/ui, keeping any you already have.

API

Read from the source above: each parameter with its type, its default, and what it is for.

ChartExportControllerclass

Takes pictures of the ChartExport it is given to.

ChartExportclass

Marks what a ChartExportController takes pictures of.

Parameter Type Default What it is for
controllerrequired ChartExportController
childrequired Widget

chartPdffunction

A one-page PDF holding a picture given as straight (not premultiplied) RGBA bytes, width by height pixels; the page is the picture's size at 96 pixels an inch, divided by pixelRatio.

Parameter Type Default What it is for
rgbarequired Uint8List
widthrequired int
heightrequired int
pixelRatiodouble1
titleString?
background Color const Color(0xFFFFFFFF)

Source

What lands in your app. It is yours from then on: change it as you would your own code, and ashlar add will not write over it.

lib/ui/chart_export.dart 154 lines
// Ashlar UI: Chart export. Copied into your app by `ashlar add chart-export`; it is yours
// to change.
//
// A chart (or any widget) as a PNG or a one-page PDF, with no package: the picture from
// Flutter's own renderer, the PDF written here. What you do with the bytes (save, share,
// upload) is up to your app.

import 'dart:convert';
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

/// Takes pictures of the [ChartExport] it is given to.
///
/// ```dart
/// final export = ChartExportController();
/// ChartExport(controller: export, child: BarChart(...));
/// final bytes = await export.pdf(title: 'Visitors');
/// ```
class ChartExportController {
  final _key = GlobalKey();

  RenderRepaintBoundary get _boundary {
    final r = _key.currentContext?.findRenderObject();
    if (r is! RenderRepaintBoundary) {
      throw StateError('The ChartExport for this controller is not on screen.');
    }
    return r;
  }

  /// The chart as an image, [pixelRatio] pixels to each logical one.
  Future<ui.Image> image({double pixelRatio = 2}) =>
      _boundary.toImage(pixelRatio: pixelRatio);

  /// The chart as a PNG.
  Future<Uint8List> png({double pixelRatio = 2}) async {
    final image = await this.image(pixelRatio: pixelRatio);
    try {
      final data = await image.toByteData(format: ui.ImageByteFormat.png);
      return data!.buffer.asUint8List();
    } finally {
      image.dispose();
    }
  }

  /// The chart as a one-page PDF the size of the chart, on [background] (the chart's
  /// transparent parts are painted it), with [title] in the document's details.
  Future<Uint8List> pdf({
    double pixelRatio = 2,
    String? title,
    Color background = const Color(0xFFFFFFFF),
  }) async {
    final image = await this.image(pixelRatio: pixelRatio);
    try {
      final data = await image.toByteData(format: ui.ImageByteFormat.rawStraightRgba);
      return chartPdf(
        rgba: data!.buffer.asUint8List(),
        width: image.width,
        height: image.height,
        pixelRatio: pixelRatio,
        title: title,
        background: background,
      );
    } finally {
      image.dispose();
    }
  }
}

/// Marks what a [ChartExportController] takes pictures of.
class ChartExport extends StatelessWidget {
  const ChartExport({super.key, required this.controller, required this.child});

  final ChartExportController controller;
  final Widget child;

  @override
  Widget build(BuildContext context) =>
      RepaintBoundary(key: controller._key, child: child);
}

/// A one-page PDF holding a picture given as straight (not premultiplied) RGBA bytes,
/// [width] by [height] pixels; the page is the picture's size at 96 pixels an inch,
/// divided by [pixelRatio].
Uint8List chartPdf({
  required Uint8List rgba,
  required int width,
  required int height,
  double pixelRatio = 1,
  String? title,
  Color background = const Color(0xFFFFFFFF),
}) {
  // PDF pictures have no transparency here: lay each pixel on the background.
  final rgb = Uint8List(width * height * 3);
  final br = (background.r * 255).round(),
      bg = (background.g * 255).round(),
      bb = (background.b * 255).round();
  for (var p = 0, q = 0; p < rgba.length; p += 4, q += 3) {
    final a = rgba[p + 3];
    rgb[q] = (rgba[p] * a + br * (255 - a)) ~/ 255;
    rgb[q + 1] = (rgba[p + 1] * a + bg * (255 - a)) ~/ 255;
    rgb[q + 2] = (rgba[p + 2] * a + bb * (255 - a)) ~/ 255;
  }
  // Points are 1/72 inch; a logical pixel is 1/96.
  String n(double v) => v.toStringAsFixed(2);
  final pw = width / pixelRatio * .75, ph = height / pixelRatio * .75;
  final out = BytesBuilder(copy: false);
  final offsets = <int>[];
  void raw(String s) => out.add(latin1.encode(s));
  void obj(String body) {
    offsets.add(out.length);
    raw('${offsets.length} 0 obj\n$body\nendobj\n');
  }

  // Text in a PDF string: brackets and backslashes escaped, other characters dropped
  // unless they fit Latin-1.
  String text(String s) => s
      .replaceAll(r'\', r'\\')
      .replaceAll('(', r'\(')
      .replaceAll(')', r'\)')
      .runes
      .map((r) => r < 256 ? String.fromCharCode(r) : '?')
      .join();

  raw('%PDF-1.4\n%âãÏÓ\n');
  obj('<< /Type /Catalog /Pages 2 0 R >>');
  obj('<< /Type /Pages /Kids [3 0 R] /Count 1 >>');
  obj(
    '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${n(pw)} ${n(ph)}] '
    '/Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>',
  );
  offsets.add(out.length);
  raw(
    '4 0 obj\n<< /Type /XObject /Subtype /Image /Width $width /Height $height '
    '/ColorSpace /DeviceRGB /BitsPerComponent 8 /Length ${rgb.length} >>\nstream\n',
  );
  out.add(rgb);
  raw('\nendstream\nendobj\n');
  final draw = 'q ${n(pw)} 0 0 ${n(ph)} 0 0 cm /Im0 Do Q';
  obj('<< /Length ${draw.length} >>\nstream\n$draw\nendstream');
  obj('<< /Title (${text(title ?? 'Chart')}) /Producer (Ashlar UI) >>');
  final xref = out.length;
  raw('xref\n0 ${offsets.length + 1}\n0000000000 65535 f \n');
  for (final o in offsets) {
    raw('${o.toString().padLeft(10, '0')} 00000 n \n');
  }
  raw(
    'trailer\n<< /Size ${offsets.length + 1} /Root 1 0 R /Info ${offsets.length} 0 R >>\n'
    'startxref\n$xref\n%%EOF\n',
  );
  return out.toBytes();
}