buildNrbLocalPdf function

Future<Uint8List> buildNrbLocalPdf(
  1. Map<String, dynamic> payload,
  2. List<List<Map<String, dynamic>>> headers,
  3. List<List<Map<String, dynamic>>> rows,
  4. List<ByteData> fontData,
  5. List<double> pixelWidths,
)

Builds a PDF from plain data inside the local export worker.

Embedded Noto Sans fonts cover Bengali, Latin, Greek, and Cyrillic. Bengali uses OpenType shaping and preserves logical Unicode in PDF ActualText spans.

Implementation

Future<Uint8List> buildNrbLocalPdf(
  Map<String, dynamic> payload,
  List<List<Map<String, dynamic>>> headers,
  List<List<Map<String, dynamic>>> rows,
  List<ByteData> fontData,
  List<double> pixelWidths,
) async {
  final document = PdfDocument();
  final fonts = fontData
      .take(4)
      .map((data) => BanglaUnicodeFont.tryParse(data)!)
      .toList();
  final bengaliFonts = fontData
      .skip(4)
      .map((data) => BanglaUnicodeFont.tryParse(data)!)
      .toList();

  final widths = pixelWidths.map((w) => w * 0.75).toList();
  final totalWidth = widths.fold<double>(0, (a, b) => a + b);
  // A PDF page must not exceed 14,400 points without a UserUnit override.
  final scale = math.min(1.0, 14340 / totalWidth);
  for (var i = 0; i < widths.length; i++) {
    widths[i] *= scale;
  }
  final pageWidth = math.max(
    595.28,
    widths.fold<double>(0, (a, b) => a + b) + 60,
  );
  const pageHeight = 841.89;
  const margin = 30.0;
  const bottom = 45.0;
  const fontSize = 8.0;
  const lineHeight = fontSize * 1.8;
  final columnX = <double>[margin];
  for (final width in widths) {
    columnX.add(columnX.last + width);
  }

  BanglaUnicodeFont fontFor(Map<String, dynamic> cell, {bool header = false}) {
    final bold =
        cell['is_bold'] == true || (header && cell['is_bold'] != false);
    return fonts[(bold ? 1 : 0) + (cell['is_italic'] == true ? 2 : 0)];
  }

  PdfColor color(Object? value, String fallback) {
    var hex = (value ?? fallback).toString().replaceAll('#', '');
    if (hex.length == 8) hex = hex.substring(2);
    if (!RegExp(r'^[0-9a-fA-F]{6}$').hasMatch(hex)) hex = fallback;
    return PdfColor.fromHex(hex);
  }

  final layoutContext = pw.Context(document: document);
  final bengaliRange = RegExp(r'[\u0980-\u09ff]');
  ShapedTextWidget paragraph(
    String text,
    BanglaUnicodeFont latinFont,
    double size,
    double width,
    PdfColor foreground, {
    String alignment = 'left',
  }) {
    final fontIndex = fonts.indexOf(latinFont);
    final bengali = bengaliFonts[fontIndex.isOdd ? 1 : 0];
    final hasBengali = bengaliRange.hasMatch(text);
    final primary = hasBengali ? bengali : latinFont;
    final fallback = hasBengali ? latinFont : bengali;
    final cleaned = text
        .replaceAll('\r\n', '\n')
        .replaceAll('\r', '\n')
        .replaceAll('\t', '    ')
        .replaceAll(RegExp(r'[\x00-\x08\x0b\x0c\x0e-\x1f]'), '');
    for (final rune in cleaned.runes) {
      if (rune == 10 || rune == 0x200c || rune == 0x200d) continue;
      if (primary.otf.glyphForRune(rune) == null &&
          fallback.otf.glyphForRune(rune) == null) {
        throw FormatException(
          'Local PDF does not support U+${rune.toRadixString(16).toUpperCase()}. '
          'Use Excel or Word for text outside the bundled Bengali, Latin, Greek and Cyrillic fonts.',
        );
      }
    }
    final result = ShapedTextWidget(
      text: cleaned,
      font: primary,
      fallbackFonts: [fallback],
      fontSize: size,
      color: foreground,
      lineSpacing: 1.8 / (primary.ascent - primary.descent),
      textAlign: alignment == 'right'
          ? pw.TextAlign.right
          : alignment == 'center'
          ? pw.TextAlign.center
          : pw.TextAlign.left,
    );
    result.layout(layoutContext, pw.BoxConstraints(maxWidth: width));
    return result;
  }

  List<String> wrap(
    String text,
    BanglaUnicodeFont font,
    double size,
    double width,
  ) => paragraph(text, font, size, width, PdfColors.black).lineTexts;

  final headerCells = <_PdfHeader>[];
  final headerHeights = List<double>.filled(headers.length, 24);
  final occupied = <String>{};
  for (var r = 0; r < headers.length; r++) {
    var c = 0;
    for (final cell in headers[r]) {
      while (occupied.contains('$r:$c')) {
        c++;
      }
      final cs = int.parse('${cell['col_span'] ?? 1}');
      final rs = int.parse('${cell['row_span'] ?? 1}');
      final lines = wrap(
        '${cell['text'] ?? ''}',
        fontFor(cell, header: true),
        fontSize,
        columnX[c + cs] - columnX[c] - 8,
      );
      final available = headerHeights
          .skip(r)
          .take(rs)
          .fold<double>(0, (a, b) => a + b);
      final extra =
          math.max(0.0, lines.length * lineHeight + 8 - available) / rs;
      for (var rr = r; rr < r + rs; rr++) {
        headerHeights[rr] += extra;
        for (var cc = c; cc < c + cs; cc++) {
          occupied.add('$rr:$cc');
        }
      }
      headerCells.add(_PdfHeader(cell, r, c, rs, cs, lines));
      c += cs;
    }
  }
  final headerHeight = headerHeights.fold<double>(0, (a, b) => a + b);
  PdfImage? logo;
  final encodedLogo = payload['_nrb_logo_encoded'];
  if (encodedLogo is Uint8List) {
    logo = PdfImage.file(document, bytes: encodedLogo);
  } else if (payload['_nrb_logo_rgb'] != null) {
    final rgb = payload['_nrb_logo_rgb'];
    final bytes = rgb is String ? base64Decode(rgb) : rgb as Uint8List;
    final w = payload['_nrb_logo_width'] as int? ?? 0;
    final h = payload['_nrb_logo_height'] as int? ?? 0;
    if (w <= 0 || h <= 0 || bytes.length != w * h * 3) {
      throw const FormatException(
        'PDF logo dimensions do not match its RGB bytes.',
      );
    }
    logo = PdfImage(document, image: bytes, width: w, height: h, alpha: false);
  }
  late PdfGraphics canvas;
  var y = 0.0;
  var dataTop = 0.0;
  var pageNumber = 0;

  void drawLines(
    List<String> lines,
    BanglaUnicodeFont font,
    double size,
    double x,
    double top,
    double width,
    String alignment,
    PdfColor foreground,
  ) {
    for (var i = 0; i < lines.length; i++) {
      final widget = paragraph(
        lines[i],
        font,
        size,
        width,
        foreground,
        alignment: alignment,
      );
      final height = size * 1.8;
      widget.box = PdfRect(x, top - (i + 1) * height, width, height);
      widget.paint(pw.Context(document: document, canvas: canvas));
    }
  }

  void drawCell(
    Map<String, dynamic> cell,
    List<String> lines,
    double x,
    double top,
    double width,
    double height, {
    bool header = false,
  }) {
    canvas.setFillColor(color(cell['bg_hex'], header ? '4CAF50' : 'FFFFFF'));
    canvas.drawRect(x, top - height, width, height);
    canvas.fillPath();
    canvas.setStrokeColor(PdfColors.grey);
    canvas.setLineWidth(0.35);
    canvas.drawRect(x, top - height, width, height);
    canvas.strokePath();
    drawLines(
      lines,
      fontFor(cell, header: header),
      fontSize,
      x + 4,
      top - 4,
      width - 8,
      header ? 'center' : '${cell['align'] ?? 'left'}',
      color(cell['fg_hex'], header ? 'FFFFFF' : '000000'),
    );
  }

  void newPage() {
    final page = PdfPage(
      document,
      pageFormat: PdfPageFormat(pageWidth, pageHeight),
    );
    canvas = page.getGraphics();
    pageNumber++;
    y = pageHeight - margin;
    final headingX = margin + (logo == null ? 0 : 90);
    final headingTop = y;
    if (logo != null) {
      final ratio = math.min(78 / logo.width, 54 / logo.height);
      canvas.drawImage(
        logo,
        margin,
        y - logo.height * ratio,
        logo.width * ratio,
        logo.height * ratio,
      );
    }
    for (final entry in [
      ('${payload['_nrb_company_name'] ?? ''}', 13.0, fonts[1]),
      ('${payload['_nrb_company_address'] ?? ''}', 9.0, fonts[0]),
    ]) {
      if (entry.$1.trim().isEmpty) continue;
      final lines = wrap(
        entry.$1,
        entry.$3,
        entry.$2,
        pageWidth - headingX - margin,
      );
      drawLines(
        lines,
        entry.$3,
        entry.$2,
        headingX,
        y,
        pageWidth - headingX - margin,
        'left',
        PdfColors.black,
      );
      y -= lines.length * entry.$2 * 1.8 + 6;
    }
    if (logo != null) y = math.min(y, headingTop - 62);
    final title =
        '${payload['report_name'] ?? payload['fileName'] ?? 'NRB Report'}';
    final titleLines = wrap(title, fonts[1], 12, pageWidth - 2 * margin);
    drawLines(
      titleLines,
      fonts[1],
      12,
      margin,
      y,
      pageWidth - 2 * margin,
      'left',
      PdfColors.black,
    );
    y -= titleLines.length * 12 * 1.8 + 12;
    if (y - headerHeight < bottom + 24) {
      throw const FormatException(
        'PDF branding and headers leave no room for data.',
      );
    }
    for (final header in headerCells) {
      final top =
          y - headerHeights.take(header.row).fold<double>(0, (a, b) => a + b);
      final height = headerHeights
          .skip(header.row)
          .take(header.rowSpan)
          .fold<double>(0, (a, b) => a + b);
      drawCell(
        header.cell,
        header.lines,
        columnX[header.column],
        top,
        columnX[header.column + header.colSpan] - columnX[header.column],
        height,
        header: true,
      );
    }
    y -= headerHeight;
    dataTop = y;
    drawLines(
      ['Page $pageNumber | Generated locally by NRB'],
      fonts[0],
      7,
      margin,
      26,
      pageWidth - margin * 2,
      'left',
      PdfColors.grey,
    );
  }

  newPage();
  for (final row in rows) {
    final cells = List.generate(
      widths.length,
      (i) => i < row.length ? row[i] : <String, dynamic>{},
    );
    final lines = List.generate(
      widths.length,
      (i) => wrap(
        '${cells[i]['value'] ?? ''}',
        fontFor(cells[i]),
        fontSize,
        widths[i] - 8,
      ),
    );
    final count = lines.fold<int>(
      1,
      (n, cellLines) => math.max(n, cellLines.length),
    );
    var offset = 0;
    if (count * lineHeight + 8 > y - bottom && y != dataTop) newPage();
    while (offset < count) {
      final capacity = ((y - bottom - 8) / lineHeight).floor();
      if (capacity < 1) {
        newPage();
        continue;
      }
      final take = math.min(capacity, count - offset);
      final height = math.max(21.0, take * lineHeight + 8);
      for (var c = 0; c < cells.length; c++) {
        drawCell(
          cells[c],
          lines[c].skip(offset).take(take).toList(),
          columnX[c],
          y,
          widths[c],
          height,
        );
      }
      y -= height;
      offset += take;
      if (offset < count) newPage();
    }
  }
  return document.save();
}