diff --git a/src/ocrmypdf/hocr.py b/src/ocrmypdf/hocr.py new file mode 100644 index 00000000..ff177a65 --- /dev/null +++ b/src/ocrmypdf/hocr.py @@ -0,0 +1,266 @@ +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 711a471c..5f30c9d9 100755 --- a/src/ocrmypdf/hocrtransform/__init__.py +++ b/src/ocrmypdf/hocrtransform/__init__.py @@ -11,21 +11,19 @@ from __future__ import annotations import argparse import os import re -import warnings from math import atan, cos, sin from pathlib import Path from typing import Any, NamedTuple from xml.etree import ElementTree -with warnings.catch_warnings(): - # reportlab uses deprecated load_module - # shim can be removed when we require reportlab >= 3.7 - warnings.filterwarnings( - 'ignore', category=DeprecationWarning, message=r".*load_module.*" - ) - from reportlab.lib.colors import black, cyan, magenta, red - from reportlab.lib.units import inch - from reportlab.pdfgen.canvas import Canvas +from ocrmypdf.hocrtransform.backends.reportlab import ( + Canvas, + black, + cyan, + inch, + magenta, + red, +) # According to Wikipedia these languages are supported in the ISO-8859-1 character # set, meaning reportlab can generate them and they are compatible with hocr, diff --git a/src/ocrmypdf/hocrtransform/backends/__init__.py b/src/ocrmypdf/hocrtransform/backends/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/ocrmypdf/hocrtransform/backends/reportlab.py b/src/ocrmypdf/hocrtransform/backends/reportlab.py new file mode 100644 index 00000000..e9b1fc2d --- /dev/null +++ b/src/ocrmypdf/hocrtransform/backends/reportlab.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import warnings + +with warnings.catch_warnings(): + # reportlab uses deprecated load_module + # shim can be removed when we require reportlab >= 3.7 + warnings.filterwarnings( + 'ignore', category=DeprecationWarning, message=r".*load_module.*" + ) + from reportlab.lib.colors import black, cyan, magenta, red + from reportlab.lib.units import inch + from reportlab.pdfgen.canvas import Canvas