Move canvas API to pikepdf and import it

This commit is contained in:
James R. Barlow
2023-12-02 19:42:35 -08:00
parent e97f89de3b
commit 43618e6b3f
5 changed files with 123 additions and 529 deletions
+1 -1
View File
@@ -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",
]
-502
View File
@@ -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> <FFFF>\n"
b"endcodespacerange\n"
b"1 beginbfrange\n"
b"<0000> <FFFF> <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
+112
View File
@@ -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> <FFFF>\n"
b"endcodespacerange\n"
b"1 beginbfrange\n"
b"<0000> <FFFF> <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
+10 -12
View File
@@ -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,
-14
View File
@@ -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)