diff --git a/pyproject.toml b/pyproject.toml index 1dd4ace6..4e30944b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "img2pdf>=0.4.4", "packaging>=20", "pdfminer.six>=20220319", - "pikepdf>=8.7.1", + "pikepdf>=8.8.0", "pluggy>=0.13.0", "rich>=13", ] diff --git a/src/ocrmypdf/hocrtransform/_canvas.py b/src/ocrmypdf/hocrtransform/_canvas.py deleted file mode 100644 index caeecae1..00000000 --- a/src/ocrmypdf/hocrtransform/_canvas.py +++ /dev/null @@ -1,502 +0,0 @@ -# SPDX-FileCopyrightText: 2023 James R. Barlow -# SPDX-License-Identifier: MPL-2.0 - -from __future__ import annotations - -import logging -import unicodedata -import zlib -from contextlib import contextmanager -from dataclasses import dataclass -from enum import Enum -from importlib.resources import files as package_files -from pathlib import Path - -from pikepdf import ( - ContentStreamInstruction, - Dictionary, - Matrix, - Name, - Operator, - Pdf, - unparse_content_stream, -) -from PIL import Image - -from .color import Color - -log = logging.getLogger(__name__) - - -class TextDirection(Enum): - LTR = 1 # Left to right: the default - RTL = 2 # Right to left: Arabic, Hebrew, Persian - - -class Font: - def text_width(self, text: str, fontsize: float) -> int: - """Estimate the width of a text string when rendered with the given font.""" - raise NotImplementedError - - def register(self, pdf: Pdf): - """Register the 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. - """ - raise NotImplementedError - - -class GlyphlessFont(Font): - CID_TO_GID_DATA = zlib.compress(b"\x00\x01" * 65536) - GLYPHLESS_FONT_NAME = 'pdf.ttf' - GLYPHLESS_FONT = (package_files('ocrmypdf.data') / GLYPHLESS_FONT_NAME).read_bytes() - CHAR_ASPECT = 2 - - def __init__(self): - pass - - def text_width(self, text: str, fontsize: float) -> int: - """Estimate the width of a text string when rendered with the given font.""" - # NFKC: split ligatures, combine diacritics - return len(unicodedata.normalize("NFKC", text)) * (fontsize / self.CHAR_ASPECT) - - def register(self, 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 // self.CHAR_ASPECT, - ) - ) - basefont.DescendantFonts = [cid_font_type2] - cid_font_type2.CIDToGIDMap = pdf.make_stream( - self.CID_TO_GID_DATA, Filter=Name.FlateDecode - ) - 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 // self.CHAR_ASPECT, 1000], - FontFile2=PLACEHOLDER, - FontName=Name.GlyphLessFont, - ItalicAngle=0, - StemV=80, - Type=Name.FontDescriptor, - ) - ) - font_descriptor.FontFile2 = pdf.make_stream(self.GLYPHLESS_FONT) - cid_font_type2.FontDescriptor = font_descriptor - return basefont - - -class ContentStreamBuilder: - def __init__(self): - self._stream = b"" - - def _append(self, inst: ContentStreamInstruction): - self._stream += unparse_content_stream([inst]) + b"\n" - - def extend(self, other: ContentStreamBuilder): - self._stream += other._stream - - def push(self): - """Save the graphics state.""" - inst = ContentStreamInstruction([], Operator("q")) - self._append(inst) - return self - - def pop(self): - """Restore the graphics state.""" - inst = ContentStreamInstruction([], Operator("Q")) - self._append(inst) - return self - - def cm(self, matrix: Matrix): - """Concatenate matrix.""" - inst = ContentStreamInstruction(matrix.shorthand, Operator("cm")) - self._append(inst) - return self - - def begin_text(self): - """Begin text object.""" - inst = ContentStreamInstruction([], Operator("BT")) - self._append(inst) - return self - - def end_text(self): - """End text object.""" - inst = ContentStreamInstruction([], Operator("ET")) - self._append(inst) - return self - - def begin_marked_content_proplist(self, mctype: Name, mcid: int): - """Begin marked content sequence.""" - inst = ContentStreamInstruction( - [mctype, Dictionary(MCID=mcid)], Operator("BDC") - ) - self._append(inst) - return self - - def begin_marked_content(self, mctype: Name): - """Begin marked content sequence.""" - inst = ContentStreamInstruction([mctype], Operator("BMC")) - self._append(inst) - return self - - def end_marked_content(self): - """End marked content sequence.""" - inst = ContentStreamInstruction([], Operator("EMC")) - self._append(inst) - return self - - def set_text_font(self, font: Name, size: int): - """Set text font and size.""" - inst = ContentStreamInstruction([font, size], Operator("Tf")) - self._append(inst) - return self - - def set_text_matrix(self, matrix: Matrix): - """Set text matrix.""" - inst = ContentStreamInstruction(matrix.shorthand, Operator("Tm")) - self._append(inst) - return self - - def set_text_rendering(self, mode: int): - """Set text rendering mode.""" - inst = ContentStreamInstruction([mode], Operator("Tr")) - self._append(inst) - return self - - def set_text_horizontal_scaling(self, scale: float): - """Set text horizontal scaling.""" - inst = ContentStreamInstruction([scale], Operator("Tz")) - self._append(inst) - return self - - def show_text(self, text: str): - """Show text.""" - encoded = text.encode("utf-16be") - inst = ContentStreamInstruction([[encoded]], Operator("TJ")) - self._append(inst) - return self - - def move_cursor(self, dx, dy): - """Move cursor.""" - inst = ContentStreamInstruction([dx, dy], Operator("Td")) - self._append(inst) - return self - - def stroke_and_close(self): - """Stroke and close path.""" - inst = ContentStreamInstruction([], Operator("s")) - self._append(inst) - return self - - def fill(self): - """Stroke and close path.""" - inst = ContentStreamInstruction([], Operator("f")) - self._append(inst) - return self - - def append_rectangle(self, x: float, y: float, w: float, h: float): - """Append rectangle to path.""" - inst = ContentStreamInstruction([x, y, w, h], Operator("re")) - self._append(inst) - return self - - def set_stroke_color(self, r: float, g: float, b: float): - """Set RGB stroke color.""" - inst = ContentStreamInstruction([r, g, b], Operator("RG")) - self._append(inst) - return self - - def set_fill_color(self, r: float, g: float, b: float): - """Set RGB fill color.""" - inst = ContentStreamInstruction([r, g, b], Operator("rg")) - self._append(inst) - return self - - def set_line_width(self, width): - """Set line width.""" - inst = ContentStreamInstruction([width], Operator("w")) - self._append(inst) - return self - - def line(self, x1: float, y1: float, x2: float, y2: float): - """Draw line.""" - insts = [ - ContentStreamInstruction([x1, y1], Operator("m")), - ContentStreamInstruction([x2, y2], Operator("l")), - ] - self._append(insts[0]) - self._append(insts[1]) - return self - - def set_dashes(self, array=None, phase=0): - """Set dashes.""" - if array is None: - array = [] - if isinstance(array, (int, float)): - array = (array, phase) - phase = 0 - inst = ContentStreamInstruction([array, phase], Operator("d")) - self._append(inst) - return self - - def draw_form_xobject(self, name: Name): - inst = ContentStreamInstruction([name], Operator("Do")) - self._append(inst) - return self - - def build(self): - return self._stream - - -@dataclass -class LoadedImage: - name: Name - image: Image.Image - - -class _PikepdfCanvasAccessor: - """Support class for drawing on a pikepdf canvas.""" - - def __init__(self, cs: ContentStreamBuilder, images=None): - self._cs = cs - self._images = images if images is not None else [] - self._stack_depth = 0 - - def stroke_color(self, color: Color): - """Set stroke color.""" - r, g, b = color.red, color.green, color.blue - self._cs.set_stroke_color(r, g, b) - return self - - def fill_color(self, color: Color): - """Set fill color.""" - r, g, b = color.red, color.green, color.blue - self._cs.set_fill_color(r, g, b) - return self - - def line_width(self, width): - """Set line width.""" - self._cs.set_line_width(width) - return self - - def line(self, x1, y1, x2, y2): - """Draw line from (x1,y1) to (x2,y2).""" - self._cs.line(x1, y1, x2, y2) - self._cs.stroke_and_close() - return self - - def rect(self, x, y, w, h, fill): - """Draw optionally filled rectangle at (x,y) with width w and height h.""" - self._cs.append_rectangle(x, y, w, h) - if fill: - self._cs.fill() - else: - self._cs.stroke_and_close() - return self - - def draw_image(self, image: Path | str | Image.Image, x, y, width, height): - """Draw image at (x,y) with width w and height h.""" - with self.save_state(cm=Matrix(width, 0, 0, height, x, y)): - if isinstance(image, (Path, str)): - image = Image.open(image) - image.load() - if image.mode == "P": - image = image.convert("RGB") - if image.mode not in ("1", "L", "RGB"): - raise ValueError(f"Unsupported image mode: {image.mode}") - name = Name.random(prefix="Im") - li = LoadedImage(name, image) - self._images.append(li) - self._cs.draw_form_xobject(name) - return self - - def draw_text(self, text: PikepdfText): - """Draw text object.""" - self._cs.extend(text._cs) - self._cs.end_text() - return self - - def dashes(self, *args): - """Set dashes.""" - self._cs.set_dashes(*args) - return self - - def push(self): - """Save the graphics state.""" - self._cs.push() - self._stack_depth += 1 - return self - - def pop(self): - """Restore the graphics state.""" - self._cs.pop() - self._stack_depth -= 1 - return self - - @contextmanager - def save_state(self, *, cm: Matrix | None = None): - """Save the graphics state and restore it on exit. - - Optionally, concatenate a transformation matrix. Implements - the commonly used pattern of: - q cm ... Q - """ - self.push() - if cm is not None: - self.cm(cm) - yield self - self.pop() - - def cm(self, matrix: Matrix): - """Concatenate a new transformation matrix to the current matrix.""" - self._cs.cm(matrix) - return self - - -class PikepdfCanvas: - """Canvas for rendering PDFs with pikepdf. - - All drawing is done on a pikepdf canvas using the .do property. - This interface manages the graphics state of the canvas and saves it. - """ - - def __init__(self, *, page_size: tuple[int | float, int | float]): - self.page_size = page_size - self._pdf = Pdf.new() - self._page = self._pdf.add_blank_page(page_size=page_size) - self._page.Resources = Dictionary(Font=Dictionary(), XObject=Dictionary()) - self._cs = ContentStreamBuilder() - self._images: list[LoadedImage] = [] - self._accessor = _PikepdfCanvasAccessor(self._cs, self._images) - self._stack_depth = 0 - self.do.push() - - def add_font(self, resource_name: Name, font: Font): - """Add a font to the page.""" - self._page.Resources.Font[resource_name] = font.register(self._pdf) - - @property - def do(self) -> _PikepdfCanvasAccessor: - """Do operations on the current graphics state.""" - return self._accessor - - def _save_image(self, li: LoadedImage): - return self._pdf.make_stream( - li.image.tobytes(), - Width=li.image.width, - Height=li.image.height, - ColorSpace=Name.DeviceGray - if li.image.mode in ("1", "L") - else Name.DeviceRGB, - Type=Name.XObject, - Subtype=Name.Image, - BitsPerComponent=1 if li.image.mode == '1' else 8, - ) - - def save(self, output_file: Path): - """Save the page to the output file.""" - self.do.pop() - if self._stack_depth != 0: - log.warning( - "Graphics state stack is not empty when page saved - " - "rendering may be incorrect" - ) - self._page.Contents = self._pdf.make_stream(self._cs.build()) - for li in self._images: - self._page.Resources.XObject[li.name] = self._save_image(li) - self._pdf.save(output_file) - - -class PikepdfText: - """Text object for rendering text on a pikepdf canvas.""" - - def __init__(self, direction=TextDirection.LTR): - self._cs = ContentStreamBuilder() - self._cs.begin_text() - self._direction = direction - - def font(self, font: Name, size: float): - self._cs.set_text_font(font, size) - return self - - def render_mode(self, mode): - self._cs.set_text_rendering(mode) - return self - - def text_transform(self, matrix: Matrix): - self._cs.set_text_matrix(matrix) - return self - - def show(self, text: str): - if self._direction == TextDirection.LTR: - self._cs.show_text(text) - else: - self._cs.begin_marked_content(Name.ReversedChars) - self._cs.show_text(text) - self._cs.end_marked_content() - return self - - def horiz_scale(self, scale): - self._cs.set_text_horizontal_scaling(scale) - return self - - def move_cursor(self, x, y): - self._cs.move_cursor(x, y) - return self diff --git a/src/ocrmypdf/hocrtransform/_font.py b/src/ocrmypdf/hocrtransform/_font.py new file mode 100644 index 00000000..7935cdb1 --- /dev/null +++ b/src/ocrmypdf/hocrtransform/_font.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: 2023 James R. Barlow +# SPDX-License-Identifier: MPL-2.0 + +from __future__ import annotations + +import logging +import unicodedata +import zlib +from importlib.resources import files as package_files + +from pikepdf import ( + Dictionary, + Name, + Pdf, +) +from pikepdf.canvas import Font + +log = logging.getLogger(__name__) + + +class GlyphlessFont(Font): + CID_TO_GID_DATA = zlib.compress(b"\x00\x01" * 65536) + GLYPHLESS_FONT_NAME = 'pdf.ttf' + GLYPHLESS_FONT = (package_files('ocrmypdf.data') / GLYPHLESS_FONT_NAME).read_bytes() + CHAR_ASPECT = 2 + + def __init__(self): + pass + + def text_width(self, text: str, fontsize: float) -> int: + """Estimate the width of a text string when rendered with the given font.""" + # NFKC: split ligatures, combine diacritics + return len(unicodedata.normalize("NFKC", text)) * (fontsize / self.CHAR_ASPECT) + + def register(self, 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 // self.CHAR_ASPECT, + ) + ) + basefont.DescendantFonts = [cid_font_type2] + cid_font_type2.CIDToGIDMap = pdf.make_stream( + self.CID_TO_GID_DATA, Filter=Name.FlateDecode + ) + 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 // self.CHAR_ASPECT, 1000], + FontFile2=PLACEHOLDER, + FontName=Name.GlyphLessFont, + ItalicAngle=0, + StemV=80, + Type=Name.FontDescriptor, + ) + ) + font_descriptor.FontFile2 = pdf.make_stream(self.GLYPHLESS_FONT) + cid_font_type2.FontDescriptor = font_descriptor + return basefont diff --git a/src/ocrmypdf/hocrtransform/_hocr.py b/src/ocrmypdf/hocrtransform/_hocr.py index b75e7b0c..d910dd9b 100644 --- a/src/ocrmypdf/hocrtransform/_hocr.py +++ b/src/ocrmypdf/hocrtransform/_hocr.py @@ -18,15 +18,7 @@ from pathlib import Path from xml.etree import ElementTree from pikepdf import Matrix, Name, Rectangle - -from ocrmypdf.hocrtransform._canvas import ( - Font, - GlyphlessFont, - PikepdfText, - TextDirection, -) -from ocrmypdf.hocrtransform._canvas import PikepdfCanvas as Canvas -from ocrmypdf.hocrtransform.color import ( +from pikepdf.canvas import ( BLACK, BLUE, CYAN, @@ -34,8 +26,14 @@ from ocrmypdf.hocrtransform.color import ( GREEN, MAGENTA, RED, + Canvas, + Font, + Text, + TextDirection, ) +from ocrmypdf.hocrtransform._font import GlyphlessFont + log = logging.getLogger(__name__) INCH = 72.0 @@ -232,7 +230,7 @@ class HocrTransform: ) # finish up the page and save it - canvas.save(out_filename) + canvas.to_pdf().save(out_filename) def _get_text_direction(self, par): """Get the text direction of the paragraph. @@ -305,7 +303,7 @@ class HocrTransform: ) log.debug(line_matrix) with canvas.do.save_state(cm=line_matrix): - text = PikepdfText(direction=text_direction) + text = Text(direction=text_direction) # Don't allow the font to break out of the bounding box. Division by # cos_a accounts for extra clearance between the glyph's vertical axis @@ -339,7 +337,7 @@ class HocrTransform: self, canvas: Canvas, line_matrix: Matrix, - text: PikepdfText, + text: Text, fontsize: float, elem: Element, next_elem: Element | None, diff --git a/src/ocrmypdf/hocrtransform/color.py b/src/ocrmypdf/hocrtransform/color.py deleted file mode 100644 index 47a4395a..00000000 --- a/src/ocrmypdf/hocrtransform/color.py +++ /dev/null @@ -1,14 +0,0 @@ -from __future__ import annotations - -from collections import namedtuple - -Color = namedtuple('Color', ['red', 'green', 'blue', 'alpha']) - -BLACK = Color(0, 0, 0, 1) -WHITE = Color(1, 1, 1, 1) -BLUE = Color(0, 0, 1, 1) -CYAN = Color(0, 1, 1, 1) -GREEN = Color(0, 1, 0, 1) -DARKGREEN = Color(0, 0.5, 0, 1) -MAGENTA = Color(1, 0, 1, 1) -RED = Color(1, 0, 0, 1)