Skip to content

PdfTextExtractor.extractText() throws "Null check operator used on a null value" for a page invoking a non-Form/non-Image XObject #2552

Description

@ledjoncili

Bug description

PdfTextExtractor.extractText() crashes with a TypeError (Null check operator used on a null value) when a page's content stream invokes, via the Do operator, an XObject whose /Subtype is neither /Form nor /Image (e.g. the spec-legal /Subtype /PS). This also crashes SfPdfViewer on load, because SfPdfViewer._checkVisiblePages calls extractText() automatically for every visible page (even with enableTextSelection: false).

Code sample

import 'dart:convert';
import 'dart:typed_data';

import 'package:flutter_test/flutter_test.dart';
import 'package:syncfusion_flutter_pdf/pdf.dart';

void main() {
  test('PdfTextExtractor.extractText throws on a non-Form/-Image XObject', () {
    final Uint8List bytes = _buildPdfWithNonFormXObject();

    // ignore: avoid_print
    print('PDF size: ${bytes.length} bytes');
    // ignore: avoid_print
    print('base64 (attach to the Syncfusion ticket):\n${base64.encode(bytes)}');

    final PdfDocument document = PdfDocument(inputBytes: bytes);
    addTearDown(document.dispose);

    Object? thrown;
    StackTrace? stack;
    try {
      PdfTextExtractor(document).extractText();
    } catch (e, st) {
      thrown = e;
      stack = st;
    }

    // ignore: avoid_print
    print('extractText threw: $thrown');
    // ignore: avoid_print
    print('Crash origin (expect _getXObject at pdf_text_extractor.dart:2291):');
    // ignore: avoid_print
    print(stack);

    // Documents the current (buggy) behaviour: extractText throws a null-check
    // TypeError instead of skipping the unsupported XObject. When Syncfusion
    // ships the guard, this expectation flips to returnsNormally.
    expect(thrown, isA<TypeError>());
    expect(
      stack.toString(),
      contains('pdf_text_extractor.dart'),
      reason: 'crash should originate in the text extractor',
    );
  });
}

/// Hand-builds a minimal, valid single-page PDF whose only content operator is
/// `/X0 Do`, where `/X0` is a `/Subtype /PS` XObject. xref byte offsets are
/// computed so the file parses cleanly.
Uint8List _buildPdfWithNonFormXObject() {
  const String psStream = '0 0 moveto';
  const String contentStream = 'q /X0 Do Q';

  final List<String> objects = <String>[
    '<< /Type /Catalog /Pages 2 0 R >>',
    '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
    '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] '
        '/Resources << /XObject << /X0 4 0 R >> >> /Contents 5 0 R >>',
    // Non-Form, non-Image XObject — render() returns null for this, and the
    // unguarded `!` in _getXObject then throws.
    '<< /Type /XObject /Subtype /PS /Length ${psStream.length} >>\n'
        'stream\n$psStream\nendstream',
    '<< /Length ${contentStream.length} >>\nstream\n$contentStream\nendstream',
  ];

  final StringBuffer sb = StringBuffer('%PDF-1.4\n');
  final List<int> offsets = <int>[];

  for (int i = 0; i < objects.length; i++) {
    offsets.add(sb.length);
    sb.write('${i + 1} 0 obj\n${objects[i]}\nendobj\n');
  }

  final int xrefOffset = sb.length;
  final int size = objects.length + 1;
  sb.write('xref\n0 $size\n');
  sb.write('0000000000 65535 f \n');
  for (final int off in offsets) {
    sb.write('${off.toString().padLeft(10, '0')} 00000 n \n');
  }
  sb.write('trailer\n<< /Size $size /Root 1 0 R >>\n');
  sb.write('startxref\n$xrefOffset\n%%EOF');

  return Uint8List.fromList(latin1.encode(sb.toString()));
}

On which target platforms have you observed this bug?

Web

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions