Skip to content

pdfocr_tobytes exposes four-byte row padding as black pixels to Tesseract #5073

Description

@sumit-agarwl

Description of the bug

Pixmap.pdfocr_tobytes() can add spurious right-edge characters when the input width is not divisible by four. In the attached example, Tesseract recognizes USDA Prime| even though the input image contains only USDA Prime.

MuPDF correctly needs a four-byte-aligned row stride for Leptonica, but output-pdfocr.c currently implements that alignment by increasing the pixmap's logical width. It then fills the added columns with zero, so Tesseract sees them as a full-height black bar rather than row padding.

This is below PyMuPDF's stamping or text-extraction logic: the extra | is already present in the temporary OCR PDF returned by Pixmap.pdfocr_tobytes().

How to reproduce the bug

This is the input image :

Image
from pathlib import Path

import pymupdf

image_path = Path(__file__).with_name("rapidocr_box12_exact_crop_rgb.png")
pixmap = pymupdf.Pixmap(image_path)

print("input:", pixmap.width, pixmap.height, pixmap.xres, pixmap.yres)

ocr_pdf = pixmap.pdfocr_tobytes(
    language="eng",
    tessdata=pymupdf.get_tessdata(),
)
with pymupdf.open("pdf", ocr_pdf) as document:
    print([word[4] for word in document[0].get_text("words")])

Actual:

input: 210 42 96 96
['USDA', 'Prime|']

Expected:

input: 210 42 96 96
['USDA', 'Prime']

The left side is the exact 210 x 42 RGB pixmap supplied to pdfocr_tobytes(). The right side is what MuPDF supplies to Tesseract, the current grayscale OCR bitmap: 212 x 42, with two zero-filled columns highlighted at the right edge.

Exact crop compared with MuPDF's padded Tesseract bitmap

Root cause

Current post_skew_write_header:

/* Always round the width of ocrbitmap up to a multiple of 4. */
writer->ocrbitmap = fz_new_pixmap(ctx, NULL, (w+3)&~3, h, NULL, 0);

For the reproducer, w is 210, so both the stored width and stride become 212. post_skew_write_band() explicitly zero-fills the two added columns:

for (x = writer->ocrbitmap->w - w; x > 0; x--)
    *d++ = 0;

ocr_set_image() then constructs Leptonica's header using that padded width:

Pix *image = pixCreateHeader(pix->w, pix->h, 8);

Consequently, Leptonica and Tesseract are told that all 212 columns are image content. The final two columns are not recognized as stride padding.

Possible Fix : align the stride, not the logical width

Keep ocrbitmap->w == w, while allocating a four-byte-aligned stride. The allocation size remains aligned exactly as before; only the semantic image width is corrected.

diff --git a/source/fitz/output-pdfocr.c b/source/fitz/output-pdfocr.c
index ad0121d..76c2263 100644
--- a/source/fitz/output-pdfocr.c
+++ b/source/fitz/output-pdfocr.c
@@ -324,8 +324,10 @@ post_skew_write_header(fz_context *ctx, pdfocr_band_writer *writer, int w, int h
 	writer->complen = fz_deflate_bound(ctx, (size_t)w * sh * n);
 	writer->compbuf = Memento_label(fz_malloc(ctx, writer->complen), "pdfocr_compbuf");
 
-	/* Always round the width of ocrbitmap up to a multiple of 4. */
-	writer->ocrbitmap = fz_new_pixmap(ctx, NULL, (w+3)&~3, h, NULL, 0);
+	/* Leptonica requires 8-bit rows to be four-byte aligned. Keep the real
+	 * image width and put the alignment bytes in the row stride so Tesseract
+	 * cannot interpret them as pixels. */
+	writer->ocrbitmap = fz_new_pixmap_with_data(ctx, NULL, w, h, NULL, 1, (w+3)&~3, NULL);
 	fz_set_pixmap_resolution(ctx, writer->ocrbitmap, xres, yres);
 
 	/* Send the Page Object */
@@ -447,15 +449,15 @@ post_skew_write_band(fz_context *ctx, pdfocr_band_writer *writer, int stride, in
 
 	/* Copy strip to ocrbitmap, converting if required. */
 	d = writer->ocrbitmap->samples;
-	d += band_start*w;
+	d += band_start*writer->ocrbitmap->stride;
 	if (n == 1)
 	{
 		for (y = band_height; y > 0; y--)
 		{
 			memcpy(d, sp, w);
-			if (writer->ocrbitmap->w - w)
-				memset(d + w, 0, writer->ocrbitmap->w - w);
-			d += writer->ocrbitmap->w;
+			if (writer->ocrbitmap->stride - w)
+				memset(d + w, 0, writer->ocrbitmap->stride - w);
+			d += writer->ocrbitmap->stride;
 		}
 	}
 	else
@@ -467,7 +469,7 @@ post_skew_write_band(fz_context *ctx, pdfocr_band_writer *writer, int stride, in
 				*d++ = (sp[0] + 2*sp[1] + sp[2] + 2)>>2;
 				sp += 3;
 			}
-			for (x = writer->ocrbitmap->w - w; x > 0; x--)
+			for (x = writer->ocrbitmap->stride - w; x > 0; x--)
 				*d++ = 0;
 		}
 	}
diff --git a/source/fitz/tessocr.cpp b/source/fitz/tessocr.cpp
index b017421..4dc16e2 100644
--- a/source/fitz/tessocr.cpp
+++ b/source/fitz/tessocr.cpp
@@ -197,7 +197,7 @@ ocr_set_image(fz_context *ctx, tesseract::TessBaseAPI *api, fz_pixmap *pix)
 		int x, y;
 		uint32_t *d = (uint32_t *)pix->samples;
 		for (y = pix->h; y > 0; y--)
-			for (x = pix->w>>2; x > 0; x--)
+			for (x = pix->stride>>2; x > 0; x--)
 			{
 				uint32_t v = *d;
 				((uint8_t *)d)[0] = v>>24;
@@ -302,7 +302,7 @@ void ocr_recognise(fz_context *ctx,
 		int x, y;
 		uint32_t *d = (uint32_t *)pix->samples;
 		for (y = pix->h; y > 0; y--)
-			for (x = pix->w>>2; x > 0; x--)
+			for (x = pix->stride>>2; x > 0; x--)
 			{
 				uint32_t v = *d;
 				((uint8_t *)d)[0] = v>>24;

The fz_new_pixmap_with_data() arguments intentionally mean:

  • w, h: retain the real 210 x 42 image dimensions;
  • alpha=1: create the one-component mask storage required with a NULL
    colorspace;
  • stride=(w+3)&~3: allocate 212 bytes per row for Leptonica's alignment;
  • samples=NULL: let MuPDF allocate and own the storage.

The two tessocr.cpp changes ensure the little-endian byte-order conversion still processes the complete aligned row rather than assuming width and stride are equal.

End-to-end validation

I built MuPDF at b6d17493700c621c0e70036980a6ebd06d2202c9 before and after applying only the two-file patch above. The same 210 x 42 pixels were sent through MuPDF, and the resulting PDFs were inspected with page.get_text("words"):

before: ['USDA', 'Prime|']
after:  ['USDA', 'Prime']

PyMuPDF version

1.28.0

Operating system

MacOS

Python version

3.12

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions