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 :
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.

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
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 recognizesUSDA Prime|even though the input image contains onlyUSDA Prime.MuPDF correctly needs a four-byte-aligned row stride for Leptonica, but
output-pdfocr.ccurrently 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 byPixmap.pdfocr_tobytes().How to reproduce the bug
This is the input image :
Actual:
Expected:
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.Root cause
Current
post_skew_write_header:For the reproducer,
wis 210, so both the stored width and stride become 212.post_skew_write_band()explicitly zero-fills the two added columns:ocr_set_image()then constructs Leptonica's header using that padded width: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.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 aNULLcolorspace;
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.cppchanges 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"):PyMuPDF version
1.28.0
Operating system
MacOS
Python version
3.12