Make coordinate system more consistent
This commit is contained in:
@@ -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> <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 // 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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user