From b860f0d94c7760c492f2649ffb4d362211d6cde4 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Sun, 19 Nov 2023 23:51:27 -0800 Subject: [PATCH] Make coordinate system more consistent --- src/ocrmypdf/hocr.py | 266 ------------------------- src/ocrmypdf/hocrtransform/__init__.py | 51 +++-- 2 files changed, 29 insertions(+), 288 deletions(-) delete mode 100644 src/ocrmypdf/hocr.py diff --git a/src/ocrmypdf/hocr.py b/src/ocrmypdf/hocr.py deleted file mode 100644 index ff177a65..00000000 --- a/src/ocrmypdf/hocr.py +++ /dev/null @@ -1,266 +0,0 @@ -from __future__ import annotations - -import importlib -import re -from io import BufferedReader, BufferedWriter, BytesIO -from pathlib import Path - -import pikepdf -from bs4 import BeautifulSoup -from pikepdf import ( - ContentStreamInstruction, - Dictionary, - Name, - Operator, - Pdf, - unparse_content_stream, -) - -GLYPHLESS_FONT = importlib.resources.read_binary("ocrmypdf", "pdf.ttf") -CHAR_ASPECT = 2 - - -def parse_bbox(title): - # Match for bbox pattern - bbox_pattern = re.compile(r'bbox (\d+) (\d+) (\d+) (\d+)') - match = bbox_pattern.search(title) - if match: - return tuple(map(int, match.groups())) - else: - return None - - -def register_glyphlessfont(pdf: Pdf): - """Register the glyphless font. - - Create several data structures in the Pdf to describe the font. While it create - the data, a reference should be set in at least one page's /Resources dictionary - to retain the font in the output PDF and ensure it is usable on that page. - """ - PLACEHOLDER = Name.Placeholder - - basefont = pdf.make_indirect( - Dictionary( - BaseFont=Name.GlyphLessFont, - DescendantFonts=[PLACEHOLDER], - Encoding=Name("/Identity-H"), - Subtype=Name.Type0, - ToUnicode=PLACEHOLDER, - Type=Name.Font, - ) - ) - cid_font_type2 = pdf.make_indirect( - Dictionary( - BaseFont=Name.GlyphLessFont, - CIDToGIDMap=PLACEHOLDER, - CIDSystemInfo=Dictionary( - Ordering="Identity", - Registry="Adobe", - Supplement=0, - ), - FontDescriptor=PLACEHOLDER, - Subtype=Name.CIDFontType2, - Type=Name.Font, - DW=1000 // CHAR_ASPECT, - ) - ) - basefont.DescendantFonts = [cid_font_type2] - cid_font_type2.CIDToGIDMap = pdf.make_stream(b"\x00\x01" * 65536) - basefont.ToUnicode = pdf.make_stream( - b"/CIDInit /ProcSet findresource begin\n" - b"12 dict begin\n" - b"begincmap\n" - b"/CIDSystemInfo\n" - b"<<\n" - b" /Registry (Adobe)\n" - b" /Ordering (UCS)\n" - b" /Supplement 0\n" - b">> def\n" - b"/CMapName /Adobe-Identify-UCS def\n" - b"/CMapType 2 def\n" - b"1 begincodespacerange\n" - b"<0000> \n" - b"endcodespacerange\n" - b"1 beginbfrange\n" - b"<0000> <0000>\n" - b"endbfrange\n" - b"endcmap\n" - b"CMapName currentdict /CMap defineresource pop\n" - b"end\n" - b"end\n" - ) - font_descriptor = pdf.make_indirect( - Dictionary( - Ascent=1000, - CapHeight=1000, - Descent=-1, - Flags=5, # Fixed pitch and symbolic - FontBBox=[0, 0, 1000 // CHAR_ASPECT, 1000], - FontFile2=PLACEHOLDER, - FontName=Name.GlyphLessFont, - ItalicAngle=0, - StemV=80, - Type=Name.FontDescriptor, - ) - ) - font_descriptor.FontFile2 = pdf.make_stream(GLYPHLESS_FONT) - cid_font_type2.FontDescriptor = font_descriptor - return basefont - - -class ContentStreamSequence: - def __init__(self, instructions=None): - self._instructions: list[ContentStreamInstruction] = instructions or [] - - def push(self): - """Save the graphics state.""" - inst = [ContentStreamInstruction([], Operator("q"))] - return ContentStreamSequence(self._instructions + inst) - - def pop(self): - """Restore the graphics state.""" - inst = [ContentStreamInstruction([], Operator("Q"))] - return ContentStreamSequence(self._instructions + inst) - - def cm(self, a: float, b: float, c: float, d: float, e: float, f: float): - """Concatenate matrix.""" - inst = [ContentStreamInstruction([a, b, c, d, e, f], Operator("cm"))] - return ContentStreamSequence(self._instructions + inst) - - def begin_text(self): - """Begin text object.""" - inst = [ContentStreamInstruction([], Operator("BT"))] - return ContentStreamSequence(self._instructions + inst) - - def end_text(self): - """End text object.""" - inst = [ContentStreamInstruction([], Operator("ET"))] - return ContentStreamSequence(self._instructions + inst) - - def begin_marked_content(self, mctype: Name, mcid: int): - """Begin marked content sequence.""" - inst = [ - ContentStreamInstruction([mctype, Dictionary(MCID=mcid)], Operator("BDC")) - ] - return ContentStreamSequence(self._instructions + inst) - - def end_marked_content(self): - """End marked content sequence.""" - inst = [ContentStreamInstruction([], Operator("EMC"))] - return ContentStreamSequence(self._instructions + inst) - - def set_text_font(self, font: Name, size: int): - """Set text font and size.""" - inst = [ContentStreamInstruction([font, size], Operator("Tf"))] - return ContentStreamSequence(self._instructions + inst) - - def set_text_matrix( - self, a: float, b: float, c: float, d: float, e: float, f: float - ): - """Set text matrix.""" - inst = [ContentStreamInstruction([a, b, c, d, e, f], Operator("Tm"))] - return ContentStreamSequence(self._instructions + inst) - - def set_text_rendering(self, mode: int): - """Set text rendering mode.""" - inst = [ContentStreamInstruction([mode], Operator("Tr"))] - return ContentStreamSequence(self._instructions + inst) - - def set_text_horizontal_scaling(self, scale: float): - """Set text horizontal scaling.""" - inst = [ContentStreamInstruction([scale], Operator("Tz"))] - return ContentStreamSequence(self._instructions + inst) - - def show_text(self, text: str): - """Show text.""" - inst = [ContentStreamInstruction([[text.encode("utf-16be")]], Operator("TJ"))] - return ContentStreamSequence(self._instructions + inst) - - def stroke_and_close(self): - """Stroke and close path.""" - inst = [ContentStreamInstruction([], Operator("s"))] - return ContentStreamSequence(self._instructions + inst) - - def append_rectangle(self, x: float, y: float, w: float, h: float): - """Append rectangle to path.""" - inst = [ContentStreamInstruction([x, y, w, h], Operator("re"))] - return ContentStreamSequence(self._instructions + inst) - - def set_stroke_color(self, r: float, g: float, b: float): - """Set RGB stroke color.""" - inst = [ContentStreamInstruction([r, g, b], Operator("RG"))] - return ContentStreamSequence(self._instructions + inst) - - -class ContentStreamBuilder: - def __init__(self): - self._instructions = [] - - def build(self): - return self._instructions - - def add(self, other: ContentStreamSequence): - self._instructions.extend(other._instructions) - - -def hocr_to_pdf(hocr_stream: BufferedReader, output_stream: BufferedWriter): - # Parse the hOCR data - soup = BeautifulSoup(hocr_stream, 'lxml') - - # Find the page size from the hOCR input - ocr_page = soup.find('div', class_='ocr_page') - if not ocr_page or 'title' not in ocr_page.attrs: - raise ValueError("hOCR input does not contain page information") - page_bbox = parse_bbox(ocr_page['title']) - if page_bbox is None: - raise ValueError("Could not parse page bounding box from hOCR input") - _, _, page_width, page_height = page_bbox - - # Create a new PDF with pikepdf - pdf = pikepdf.new() - page = pdf.add_blank_page(page_size=(page_width, page_height)) - - font_name = Name("/f-0-0") - - page.Resources = Dictionary( - Font=Dictionary({font_name: register_glyphlessfont(pdf)}) - ) - - cs = ContentStreamBuilder() - cs.add(ContentStreamSequence().push().cm(1, 0, 0, -1, 0, page_height)) - - # Add content using these fonts - for span in soup.find_all('span', class_='ocrx_word'): - if 'title' not in span.attrs: - continue - word_bbox = parse_bbox(span['title']) - if not word_bbox: - continue - x0, y0, x1, y1 = word_bbox - text = span.get_text() if span.get_text() else '' - - cos_a, sin_a = 1, 0 - font_size = y1 - y0 - space_width = 0 - box_width = x1 - x0 + space_width - - h_stretch = 100.0 * box_width / len(text) / font_size * CHAR_ASPECT - - cs.add( - ContentStreamSequence() - .begin_text() - .set_text_rendering(3) - .set_text_matrix(cos_a, -sin_a, sin_a, cos_a, x0, y1) - .set_text_font(font_name, font_size) - .set_text_horizontal_scaling(h_stretch) - .show_text(text) - .end_text() - ) - - cs.add(ContentStreamSequence().pop()) - - page.Contents = pdf.make_stream(unparse_content_stream(cs.build())) - - # Save the PDF to a file or return as a byte string - pdf.save(output_stream) - pdf.close() diff --git a/src/ocrmypdf/hocrtransform/__init__.py b/src/ocrmypdf/hocrtransform/__init__.py index caabaf13..e2751c90 100755 --- a/src/ocrmypdf/hocrtransform/__init__.py +++ b/src/ocrmypdf/hocrtransform/__init__.py @@ -187,12 +187,21 @@ class HocrTransform: return float(matches.group(1)), int(matches.group(2)) return (0.0, 0.0) - def pt_from_pixel(self, pxl, topdown=False) -> Rect: + def pt_from_pixel(self, pxl: Rect, bottomup=False) -> Rect: """Returns the quantity in PDF units (pt) given quantity in pixels.""" - if topdown: - pxl.y1 = self.height - pxl.y1 - pxl.y2 = self.height - pxl.y2 - return Rect._make((c / self.dpi * inch) for c in pxl) + if bottomup: + return Rect._make( + [ + (pxl.x1 / self.dpi * inch), + self.height - (pxl.y2 / self.dpi * inch), # swap y1/y2 + (pxl.x2 / self.dpi * inch), + self.height - (pxl.y1 / self.dpi * inch), + ] + ) + else: + return Rect._make( + (c / self.dpi * inch) for c in (pxl.x1, pxl.y1, pxl.x2, pxl.y2) + ) def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str: xpath = f".//{self.xmlns}{html_tag}" @@ -250,14 +259,12 @@ class HocrTransform: continue pxl_coords = self.element_coordinates(elem) - pt = self.pt_from_pixel(pxl_coords) # pylint: disable=invalid-name + pt = self.pt_from_pixel(pxl_coords, bottomup=True) # draw cyan box around paragraph if show_bounding_boxes and False: # pragma: no cover pdf.set_stroke_color(cyan) pdf.set_line_width(0.1) # no line for bounding box - pdf.rect( - pt.x1, self.height - pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1, fill=0 - ) + pdf.rect(pt.x1, pt.y2, pt.x2 - pt.x1, pt.y2 - pt.y1, fill=0) found_lines = False for line in ( @@ -314,8 +321,9 @@ class HocrTransform: if line is None: return pxl_line_coords = self.element_coordinates(line) - line_box = self.pt_from_pixel(pxl_line_coords) + line_box = self.pt_from_pixel(pxl_line_coords, bottomup=True) line_height = line_box.y2 - line_box.y1 + assert line_box.y2 > line_box.y1 slope, pxl_intercept = self.baseline(line) if abs(slope) < 0.005: @@ -341,9 +349,10 @@ class HocrTransform: if invisible_text or True: text.set_render_mode(3) # Invisible (indicates OCR text) - # Intercept is normally negative, so this places it above the bottom - # of the line box - baseline_y2 = self.height - (line_box.y2 + intercept) + # Intercept is normally negative. Subtracting it will raise the baseline + # above the bottom of the bounding box (y1). We're in page coordinates, + # origin bottom left, y2 > y1. + baseline_y1 = line_box.y1 - intercept if show_bounding_boxes and True: # pragma: no cover # draw the baseline in magenta, dashed @@ -354,11 +363,11 @@ class HocrTransform: # coordinates and page coordinates have the y axis flipped pdf.line( line_box.x1, - baseline_y2, + baseline_y1, line_box.x2, - self.polyval((-slope, baseline_y2), line_box.x2 - line_box.x1), + self.polyval((-slope, baseline_y1), line_box.x2 - line_box.x1), ) - text.set_text_transform(cos_a, -sin_a, sin_a, cos_a, line_box.x1, baseline_y2) + text.set_text_transform(cos_a, -sin_a, sin_a, cos_a, line_box.x1, baseline_y1) pdf.set_fill_color(black) # text in black elements = line.findall(self._child_xpath('span', elemclass)) @@ -369,7 +378,7 @@ class HocrTransform: continue pxl_coords = self.element_coordinates(elem) - box = self.pt_from_pixel(pxl_coords) + box = self.pt_from_pixel(pxl_coords, bottomup=True) if interword_spaces: # if `--interword-spaces` is true, append a space # to the end of each text element to allow simpler PDF viewers @@ -403,9 +412,7 @@ class HocrTransform: pdf.set_dashes() pdf.set_stroke_color(green) pdf.set_line_width(0.1) - pdf.rect( - box.x1, self.height - line_box.y2, box_width, line_height, fill=0 - ) + pdf.rect(box.x1, line_box.y1, box_width, line_height, fill=0) # Adjust relative position of cursor # This is equivalent to: @@ -422,7 +429,7 @@ class HocrTransform: if 0: cursor = text.get_start_of_line() dx = box.x1 - cursor[0] - dy = baseline_y2 - cursor[1] + dy = baseline_y1 - cursor[1] text.move_cursor(dx, dy) text.set_text_transform( cos_a, @@ -430,7 +437,7 @@ class HocrTransform: sin_a, cos_a, box.x1, - self.height - line_box.y2, + line_box.y1, ) # If reportlab tells us this word is 0 units wide, our best seems