import io
import math
import os
import typing
import weakref
from . import fitz
try:
from . import mupdf
except Exception:
import mupdf
g_exceptions_verbose = fitz.g_exceptions_verbose
g_exceptions_verbose = False
TESSDATA_PREFIX = os.environ.get("TESSDATA_PREFIX")
point_like = "point_like"
rect_like = "rect_like"
matrix_like = "matrix_like"
quad_like = "quad_like"
AnyType = typing.Any
OptInt = typing.Union[int, None]
OptFloat = typing.Optional[float]
OptStr = typing.Optional[str]
OptDict = typing.Optional[dict]
OptBytes = typing.Optional[typing.ByteString]
OptSeq = typing.Optional[typing.Sequence]
"""
This is a collection of functions to extend PyMupdf.
"""
def write_text(page: fitz.Page, **kwargs) -> None:
"""Write the text of one or more fitz.TextWriter objects.
Args:
rect: target rectangle. If None, the union of the text writers is used.
writers: one or more fitz.TextWriter objects.
overlay: put in foreground or background.
keep_proportion: maintain aspect ratio of rectangle sides.
rotate: arbitrary rotation angle.
oc: the xref of an optional content object
"""
if type(page) is not fitz.Page:
raise ValueError("bad page parameter")
s = {
k
for k in kwargs.keys()
if k
not in {
"rect",
"writers",
"opacity",
"color",
"overlay",
"keep_proportion",
"rotate",
"oc",
}
}
if s != set():
raise ValueError("bad keywords: " + str(s))
rect = kwargs.get("rect")
writers = kwargs.get("writers")
opacity = kwargs.get("opacity")
color = kwargs.get("color")
overlay = bool(kwargs.get("overlay", True))
keep_proportion = bool(kwargs.get("keep_proportion", True))
rotate = int(kwargs.get("rotate", 0))
oc = int(kwargs.get("oc", 0))
if not writers:
raise ValueError("need at least one fitz.TextWriter")
if type(writers) is fitz.TextWriter:
if rotate == 0 and rect is None:
writers.write_text(page, opacity=opacity, color=color, overlay=overlay)
return None
else:
writers = (writers,)
clip = writers[0].text_rect
textdoc = fitz.Document()
tpage = textdoc.new_page(width=page.rect.width, height=page.rect.height)
for writer in writers:
clip |= writer.text_rect
writer.write_text(tpage, opacity=opacity, color=color)
if rect is None:
rect = clip
page.show_pdf_page(
rect,
textdoc,
0,
overlay=overlay,
keep_proportion=keep_proportion,
rotate=rotate,
clip=clip,
oc=oc,
)
textdoc = None
tpage = None
def show_pdf_page(*args, **kwargs) -> int:
"""Show page number 'pno' of PDF 'src' in rectangle 'rect'.
Args:
rect: (rect-like) where to place the source image
src: (document) source PDF
pno: (int) source page number
overlay: (bool) put in foreground
keep_proportion: (bool) do not change width-height-ratio
rotate: (int) degrees (multiple of 90)
clip: (rect-like) part of source page rectangle
Returns:
xref of inserted object (for reuse)
"""
if len(args) not in (3, 4):
raise ValueError("bad number of positional parameters")
pno = None
if len(args) == 3:
page, rect, src = args
else:
page, rect, src, pno = args
if pno is None:
pno = int(kwargs.get("pno", 0))
overlay = bool(kwargs.get("overlay", True))
keep_proportion = bool(kwargs.get("keep_proportion", True))
rotate = float(kwargs.get("rotate", 0))
oc = int(kwargs.get("oc", 0))
clip = kwargs.get("clip")
def calc_matrix(sr, tr, keep=True, rotate=0):
"""Calculate transformation matrix from source to target rect.
Notes:
The product of four matrices in this sequence: (1) translate correct
source corner to origin, (2) rotate, (3) scale, (4) translate to
target's top-left corner.
Args:
sr: source rect in PDF (!) coordinate system
tr: target rect in PDF coordinate system
keep: whether to keep source ratio of width to height
rotate: rotation angle in degrees
Returns:
Transformation matrix.
"""
smp = (sr.tl + sr.br) / 2.0
tmp = (tr.tl + tr.br) / 2.0
m = fitz.Matrix(1, 0, 0, 1, -smp.x, -smp.y) * fitz.Matrix(rotate)
sr1 = sr * m
fw = tr.width / sr1.width
fh = tr.height / sr1.height
if keep:
fw = fh = min(fw, fh)
m *= fitz.Matrix(fw, fh)
m *= fitz.Matrix(1, 0, 0, 1, tmp.x, tmp.y)
return fitz.JM_TUPLE(m)
fitz.CheckParent(page)
doc = page.parent
if not doc.is_pdf or not src.is_pdf:
raise ValueError("is no PDF")
if rect.is_empty or rect.is_infinite:
raise ValueError("rect must be finite and not empty")
while pno < 0:
pno += src.page_count
src_page = src[pno]
if src_page.get_contents() == []:
raise ValueError("nothing to show - source page empty")
tar_rect = rect * ~page.transformation_matrix
src_rect = src_page.rect if not clip else src_page.rect & clip
if src_rect.is_empty or src_rect.is_infinite:
raise ValueError("clip must be finite and not empty")
src_rect = src_rect * ~src_page.transformation_matrix
matrix = calc_matrix(src_rect, tar_rect, keep=keep_proportion, rotate=rotate)
ilst = [i[1] for i in doc.get_page_xobjects(page.number)]
ilst += [i[7] for i in doc.get_page_images(page.number)]
ilst += [i[4] for i in doc.get_page_fonts(page.number)]
n = "fzFrm"
i = 0
_imgname = n + "0"
while _imgname in ilst:
i += 1
_imgname = n + str(i)
isrc = src._graft_id
if doc._graft_id == isrc:
raise ValueError("source document must not equal target")
gmap = doc.Graftmaps.get(isrc, None)
if gmap is None:
gmap = fitz.Graftmap(doc)
doc.Graftmaps[isrc] = gmap
pno_id = (isrc, pno)
xref = doc.ShownPages.get(pno_id, 0)
xref = page._show_pdf_page(
src_page,
overlay=overlay,
matrix=matrix,
xref=xref,
oc=oc,
clip=src_rect,
graftmap=gmap,
_imgname=_imgname,
)
doc.ShownPages[pno_id] = xref
return xref
def replace_image(page: fitz.Page, xref: int, *, filename=None, pixmap=None, stream=None):
"""Replace the image referred to by xref.
Replace the image by changing the object definition stored under xref. This
will leave the pages appearance instructions intact, so the new image is
being displayed with the same bbox, rotation etc.
By providing a small fully transparent image, an effect as if the image had
been deleted can be achieved.
A typical use may include replacing large images by a smaller version,
e.g. with a lower resolution or graylevel instead of colored.
Args:
xref: the xref of the image to replace.
filename, pixmap, stream: exactly one of these must be provided. The
meaning being the same as in Page.insert_image.
"""
doc = page.parent
if not doc.xref_is_image(xref):
raise ValueError("xref not an image")
if bool(filename) + bool(stream) + bool(pixmap) != 1:
raise ValueError("Exactly one of filename/stream/pixmap must be given")
new_xref = page.insert_image(
page.rect, filename=filename, stream=stream, pixmap=pixmap
)
doc.xref_copy(new_xref, xref)
last_contents_xref = page.get_contents()[-1]
doc.update_stream(last_contents_xref, b" ")
def delete_image(page: fitz.Page, xref: int):
"""Delete the image referred to by xef.
Actually replaces by a small transparent Pixmap using method Page.replace_image.
Args:
xref: xref of the image to delete.
"""
pix = fitz.Pixmap(fitz.csGRAY, (0, 0, 1, 1), 1)
pix.clear_with()
page.replace_image(xref, pixmap=pix)
def insert_image(page, rect, **kwargs):
"""Insert an image for display in a rectangle.
Args:
rect: (rect_like) position of image on the page.
alpha: (int, optional) set to 0 if image has no transparency.
filename: (str, Path, file object) image filename.
keep_proportion: (bool) keep width / height ratio (default).
mask: (bytes, optional) image consisting of alpha values to use.
oc: (int) xref of OCG or OCMD to declare as Optional Content.
overlay: (bool) put in foreground (default) or background.
pixmap: (fitz.Pixmap) use this as image.
rotate: (int) rotate by 0, 90, 180 or 270 degrees.
stream: (bytes) use this as image.
xref: (int) use this as image.
'page' and 'rect' are positional, all other parameters are keywords.
If 'xref' is given, that image is used. Other input options are ignored.
Else, exactly one of pixmap, stream or filename must be given.
'alpha=0' for non-transparent images improves performance significantly.
Affects stream and filename only.
Optimum transparent insertions are possible by using filename / stream in
conjunction with a 'mask' image of alpha values.
Returns:
xref (int) of inserted image. Re-use as argument for multiple insertions.
"""
fitz.CheckParent(page)
doc = page.parent
if not doc.is_pdf:
raise ValueError("is no PDF")
valid_keys = {
"alpha",
"filename",
"height",
"keep_proportion",
"mask",
"oc",
"overlay",
"pixmap",
"rotate",
"stream",
"width",
"xref",
}
s = set(kwargs.keys()).difference(valid_keys)
if s != set():
raise ValueError(f"bad key argument(s): {s}.")
filename = kwargs.get("filename")
pixmap = kwargs.get("pixmap")
stream = kwargs.get("stream")
mask = kwargs.get("mask")
rotate = int(kwargs.get("rotate", 0))
width = int(kwargs.get("width", 0))
height = int(kwargs.get("height", 0))
alpha = int(kwargs.get("alpha", -1))
oc = int(kwargs.get("oc", 0))
xref = int(kwargs.get("xref", 0))
keep_proportion = bool(kwargs.get("keep_proportion", True))
overlay = bool(kwargs.get("overlay", True))
if xref == 0 and (bool(filename) + bool(stream) + bool(pixmap) != 1):
raise ValueError("xref=0 needs exactly one of filename, pixmap, stream")
if filename:
if type(filename) is str:
pass
elif hasattr(filename, "absolute"):
filename = str(filename)
elif hasattr(filename, "name"):
filename = filename.name
else:
raise ValueError("bad filename")
if filename and not os.path.exists(filename):
raise FileNotFoundError("No such file: '%s'" % filename)
elif stream and type(stream) not in (bytes, bytearray, io.BytesIO):
raise ValueError("stream must be bytes-like / BytesIO")
elif pixmap and type(pixmap) is not fitz.Pixmap:
raise ValueError("pixmap must be a fitz.Pixmap")
if mask and not (stream or filename):
raise ValueError("mask requires stream or filename")
if mask and type(mask) not in (bytes, bytearray, io.BytesIO):
raise ValueError("mask must be bytes-like / BytesIO")
while rotate < 0:
rotate += 360
while rotate >= 360:
rotate -= 360
if rotate not in (0, 90, 180, 270):
raise ValueError("bad rotate value")
r = fitz.Rect(rect)
if r.is_empty or r.is_infinite:
raise ValueError("rect must be finite and not empty")
clip = r * ~page.transformation_matrix
ilst = [i[7] for i in doc.get_page_images(page.number)]
ilst += [i[1] for i in doc.get_page_xobjects(page.number)]
ilst += [i[4] for i in doc.get_page_fonts(page.number)]
n = "fzImg"
i = 0
_imgname = n + "0"
while _imgname in ilst:
i += 1
_imgname = n + str(i)
digests = doc.InsertedImages
xref, digests = page._insert_image(
filename=filename,
pixmap=pixmap,
stream=stream,
imask=mask,
clip=clip,
overlay=overlay,
oc=oc,
xref=xref,
rotate=rotate,
keep_proportion=keep_proportion,
width=width,
height=height,
alpha=alpha,
_imgname=_imgname,
digests=digests,
)
if digests is not None:
doc.InsertedImages = digests
return xref
def search_for(*args, **kwargs) -> list:
"""Search for a string on a page.
Args:
text: string to be searched for
clip: restrict search to this rectangle
quads: (bool) return quads instead of rectangles
flags: bit switches, default: join hyphened words
textpage: a pre-created fitz.TextPage
Returns:
a list of rectangles or quads, each containing one occurrence.
"""
if len(args) != 2:
raise ValueError("bad number of positional parameters")
page, text = args
quads = kwargs.get("quads", 0)
clip = kwargs.get("clip")
textpage = kwargs.get("textpage")
if clip is not None:
clip = fitz.Rect(clip)
flags = kwargs.get(
"flags",
fitz.TEXT_DEHYPHENATE
| fitz.TEXT_PRESERVE_WHITESPACE
| fitz.TEXT_PRESERVE_LIGATURES
| fitz.TEXT_MEDIABOX_CLIP,
)
fitz.CheckParent(page)
tp = textpage
if tp is None:
tp = page.get_textpage(clip=clip, flags=flags)
elif getattr(tp, "parent") != page:
raise ValueError("not a textpage of this page")
rlist = tp.search(text, quads=quads)
if textpage is None:
del tp
return rlist
def search_page_for(
doc: fitz.Document,
pno: int,
text: str,
quads: bool = False,
clip: rect_like = None,
flags: int = fitz.TEXT_DEHYPHENATE
| fitz.TEXT_PRESERVE_LIGATURES
| fitz.TEXT_PRESERVE_WHITESPACE
| fitz.TEXT_MEDIABOX_CLIP
,
textpage: fitz.TextPage = None,
) -> list:
"""Search for a string on a page.
Args:
pno: page number
text: string to be searched for
clip: restrict search to this rectangle
quads: (bool) return quads instead of rectangles
flags: bit switches, default: join hyphened words
textpage: reuse a prepared textpage
Returns:
a list of rectangles or quads, each containing an occurrence.
"""
return doc[pno].search_for(
text,
quads=quads,
clip=clip,
flags=flags,
textpage=textpage,
)
def get_text_blocks(
page: fitz.Page,
clip: rect_like = None,
flags: OptInt = None,
textpage: fitz.TextPage = None,
sort: bool = False,
) -> list:
"""Return the text blocks on a page.
Notes:
Lines in a block are concatenated with line breaks.
Args:
flags: (int) control the amount of data parsed into the textpage.
Returns:
A list of the blocks. Each item contains the containing rectangle
coordinates, text lines, block type and running block number.
"""
fitz.CheckParent(page)
if flags is None:
flags = (
fitz.TEXT_PRESERVE_WHITESPACE
| fitz.TEXT_PRESERVE_IMAGES
| fitz.TEXT_PRESERVE_LIGATURES
| fitz.TEXT_MEDIABOX_CLIP
)
tp = textpage
if tp is None:
tp = page.get_textpage(clip=clip, flags=flags)
elif getattr(tp, "parent") != page:
raise ValueError("not a textpage of this page")
blocks = tp.extractBLOCKS()
if textpage is None:
del tp
if sort is True:
blocks.sort(key=lambda b: (b[3], b[0]))
return blocks
def get_text_words(
page: fitz.Page,
clip: rect_like = None,
flags: OptInt = None,
textpage: fitz.TextPage = None,
sort: bool = False,
delimiters=None,
) -> list:
"""Return the text words as a list with the bbox for each word.
Args:
flags: (int) control the amount of data parsed into the textpage.
delimiters: (str,list) characters to use as word delimiters
Returns:
Word tuples (x0, y0, x1, y1, "word", bno, lno, wno).
"""
fitz.CheckParent(page)
if flags is None:
flags = fitz.TEXT_PRESERVE_WHITESPACE | fitz.TEXT_PRESERVE_LIGATURES | fitz.TEXT_MEDIABOX_CLIP
tp = textpage
if tp is None:
tp = page.get_textpage(clip=clip, flags=flags)
elif getattr(tp, "parent") != page:
raise ValueError("not a textpage of this page")
words = tp.extractWORDS(delimiters)
if textpage is None:
del tp
if sort is True:
words.sort(key=lambda w: (w[3], w[0]))
return words
def get_textbox(
page: fitz.Page,
rect: rect_like,
textpage: fitz.TextPage = None,
) -> str:
tp = textpage
if tp is None:
tp = page.get_textpage()
elif getattr(tp, "parent") != page:
raise ValueError("not a textpage of this page")
rc = tp.extractTextbox(rect)
if textpage is None:
del tp
return rc
def get_text_selection(
page: fitz.Page,
p1: point_like,
p2: point_like,
clip: rect_like = None,
textpage: fitz.TextPage = None,
):
fitz.CheckParent(page)
tp = textpage
if tp is None:
tp = page.get_textpage(clip=clip, flags=fitz.TEXT_DEHYPHENATE)
elif getattr(tp, "parent") != page:
raise ValueError("not a textpage of this page")
rc = tp.extractSelection(p1, p2)
if textpage is None:
del tp
return rc
def get_textpage_ocr(
page: fitz.Page,
flags: int = 0,
language: str = "eng",
dpi: int = 72,
full: bool = False,
tessdata: str = None,
) -> fitz.TextPage:
"""Create a Textpage from combined results of normal and OCR text parsing.
Args:
flags: (int) control content becoming part of the result.
language: (str) specify expected language(s). Deafault is "eng" (English).
dpi: (int) resolution in dpi, default 72.
full: (bool) whether to OCR the full page image, or only its images (default)
"""
fitz.CheckParent(page)
if not TESSDATA_PREFIX and not tessdata:
raise RuntimeError("No OCR support: TESSDATA_PREFIX not set")
def full_ocr(page, dpi, language, flags):
zoom = dpi / 72
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
ocr_pdf = fitz.Document(
"pdf",
pix.pdfocr_tobytes(
compress=False,
language=language,
tessdata=tessdata,
),
)
ocr_page = ocr_pdf.load_page(0)
unzoom = page.rect.width / ocr_page.rect.width
ctm = fitz.Matrix(unzoom, unzoom) * page.derotation_matrix
tpage = ocr_page.get_textpage(flags=flags, matrix=ctm)
ocr_pdf.close()
pix = None
tpage.parent = weakref.proxy(page)
return tpage
if full is True:
return full_ocr(page, dpi, language, flags)
tpage = page.get_textpage(flags=flags)
for block in page.get_text("dict", flags=fitz.TEXT_PRESERVE_IMAGES)["blocks"]:
if block["type"] != 1:
continue
bbox = fitz.Rect(block["bbox"])
if bbox.width <= 3 or bbox.height <= 3:
continue
try:
pix = fitz.Pixmap(block["image"])
if pix.n - pix.alpha != 3:
pix = fitz.Pixmap(fitz.csRGB, pix)
if pix.alpha:
pix = fitz.Pixmap(pix, 0)
imgdoc = fitz.Document(
"pdf",
pix.pdfocr_tobytes(language=language, tessdata=tessdata),
)
imgpage = imgdoc.load_page(0)
pix = None
imgrect = imgpage.rect
shrink = fitz.Matrix(1 / imgrect.width, 1 / imgrect.height)
mat = shrink * block["transform"]
imgpage.extend_textpage(tpage, flags=0, matrix=mat)
imgdoc.close()
except RuntimeError:
if g_exceptions_verbose: fitz.exception_info()
tpage = None
print("Falling back to full page OCR")
return full_ocr(page, dpi, language, flags)
return tpage
def get_image_info(page: fitz.Page, hashes: bool = False, xrefs: bool = False) -> list:
"""Extract image information only from a fitz.TextPage.
Args:
hashes: (bool) include MD5 hash for each image.
xrefs: (bool) try to find the xref for each image. Sets hashes to true.
"""
doc = page.parent
if xrefs and doc.is_pdf:
hashes = True
if not doc.is_pdf:
xrefs = False
imginfo = getattr(page, "_image_info", None)
if imginfo and not xrefs:
return imginfo
if not imginfo:
tp = page.get_textpage(flags=fitz.TEXT_PRESERVE_IMAGES)
imginfo = tp.extractIMGINFO(hashes=hashes)
del tp
if hashes:
page._image_info = imginfo
if not xrefs or not doc.is_pdf:
return imginfo
imglist = page.get_images()
digests = {}
for item in imglist:
xref = item[0]
pix = fitz.Pixmap(doc, xref)
digests[pix.digest] = xref
del pix
for i in range(len(imginfo)):
item = imginfo[i]
xref = digests.get(item["digest"], 0)
item["xref"] = xref
imginfo[i] = item
return imginfo
def get_image_rects(page: fitz.Page, name, transform=False) -> list:
"""Return list of image positions on a page.
Args:
name: (str, list, int) image identification. May be reference name, an
item of the page's image list or an xref.
transform: (bool) whether to also return the transformation matrix.
Returns:
A list of fitz.Rect objects or tuples of (fitz.Rect, fitz.Matrix) for all image
locations on the page.
"""
if type(name) in (list, tuple):
xref = name[0]
elif type(name) is int:
xref = name
else:
imglist = [i for i in page.get_images() if i[7] == name]
if imglist == []:
raise ValueError("bad image name")
elif len(imglist) != 1:
raise ValueError("multiple image names found")
xref = imglist[0][0]
pix = fitz.Pixmap(page.parent, xref)
digest = pix.digest
del pix
infos = page.get_image_info(hashes=True)
if not transform:
bboxes = [fitz.Rect(im["bbox"]) for im in infos if im["digest"] == digest]
else:
bboxes = [
(fitz.Rect(im["bbox"]), fitz.Matrix(im["transform"]))
for im in infos
if im["digest"] == digest
]
return bboxes
def get_text(
page: fitz.Page,
option: str = "text",
clip: rect_like = None,
flags: OptInt = None,
textpage: fitz.TextPage = None,
sort: bool = False,
delimiters=None,
):
"""Extract text from a page or an annotation.
This is a unifying wrapper for various methods of the fitz.TextPage class.
Args:
option: (str) text, words, blocks, html, dict, json, rawdict, xhtml or xml.
clip: (rect-like) restrict output to this area.
flags: bit switches to e.g. exclude images or decompose ligatures.
textpage: reuse this fitz.TextPage and make no new one. If specified,
'flags' and 'clip' are ignored.
Returns:
the output of methods get_text_words / get_text_blocks or fitz.TextPage
methods extractText, extractHTML, extractDICT, extractJSON, extractRAWDICT,
extractXHTML or etractXML respectively.
Default and misspelling choice is "text".
"""
formats = {
"text": 0,
"html": 1,
"json": 1,
"rawjson": 1,
"xml": 0,
"xhtml": 1,
"dict": 1,
"rawdict": 1,
"words": 0,
"blocks": 1,
}
option = option.lower()
if option not in formats:
option = "text"
if flags is None:
flags = fitz.TEXT_PRESERVE_WHITESPACE | fitz.TEXT_PRESERVE_LIGATURES | fitz.TEXT_MEDIABOX_CLIP
if formats[option] == 1:
flags |= fitz.TEXT_PRESERVE_IMAGES
if option == "words":
return get_text_words(
page,
clip=clip,
flags=flags,
textpage=textpage,
sort=sort,
delimiters=delimiters,
)
if option == "blocks":
return get_text_blocks(
page, clip=clip, flags=flags, textpage=textpage, sort=sort
)
fitz.CheckParent(page)
cb = None
if option in ("html", "xml", "xhtml"):
clip = page.cropbox
if clip is not None:
clip = fitz.Rect(clip)
cb = None
elif type(page) is fitz.Page:
cb = page.cropbox
tp = textpage
if tp is None:
tp = page.get_textpage(clip=clip, flags=flags)
elif getattr(tp, "parent") != page:
raise ValueError("not a textpage of this page")
if option == "json":
t = tp.extractJSON(cb=cb, sort=sort)
elif option == "rawjson":
t = tp.extractRAWJSON(cb=cb, sort=sort)
elif option == "dict":
t = tp.extractDICT(cb=cb, sort=sort)
elif option == "rawdict":
t = tp.extractRAWDICT(cb=cb, sort=sort)
elif option == "html":
t = tp.extractHTML()
elif option == "xml":
t = tp.extractXML()
elif option == "xhtml":
t = tp.extractXHTML()
else:
t = tp.extractText(sort=sort)
if textpage is None:
del tp
return t
def get_page_text(
doc: fitz.Document,
pno: int,
option: str = "text",
clip: rect_like = None,
flags: OptInt = None,
textpage: fitz.TextPage = None,
sort: bool = False,
) -> typing.Any:
"""Extract a document page's text by page number.
Notes:
Convenience function calling page.get_text().
Args:
pno: page number
option: (str) text, words, blocks, html, dict, json, rawdict, xhtml or xml.
Returns:
output from page.TextPage().
"""
return doc[pno].get_text(option, clip=clip, flags=flags, sort=sort)
def get_pixmap(
page: fitz.Page,
*,
matrix: matrix_like=fitz.Identity,
dpi=None,
colorspace: fitz.Colorspace=fitz.csRGB,
clip: rect_like=None,
alpha: bool=False,
annots: bool=True,
) -> fitz.Pixmap:
"""Create pixmap of page.
Keyword args:
matrix: Matrix for transformation (default: Identity).
dpi: desired dots per inch. If given, matrix is ignored.
colorspace: (str/Colorspace) cmyk, rgb, gray - case ignored, default csRGB.
clip: (irect-like) restrict rendering to this area.
alpha: (bool) whether to include alpha channel
annots: (bool) whether to also render annotations
"""
if dpi:
zoom = dpi / 72
matrix = fitz.Matrix(zoom, zoom)
if type(colorspace) is str:
if colorspace.upper() == "GRAY":
colorspace = fitz.csGRAY
elif colorspace.upper() == "CMYK":
colorspace = fitz.csCMYK
else:
colorspace = fitz.csRGB
if colorspace.n not in (1, 3, 4):
raise ValueError("unsupported colorspace")
dl = page.get_displaylist(annots=annots)
pix = dl.get_pixmap(matrix=matrix, colorspace=colorspace, alpha=alpha, clip=clip)
dl = None
if dpi:
pix.set_dpi(dpi, dpi)
return pix
def get_page_pixmap(
doc: fitz.Document,
pno: int,
*,
matrix: matrix_like = fitz.Identity,
dpi=None,
colorspace: fitz.Colorspace = fitz.csRGB,
clip: rect_like = None,
alpha: bool = False,
annots: bool = True,
) -> fitz.Pixmap:
"""Create pixmap of document page by page number.
Notes:
Convenience function calling page.get_pixmap.
Args:
pno: (int) page number
matrix: fitz.Matrix for transformation (default: fitz.Identity).
colorspace: (str,fitz.Colorspace) rgb, rgb, gray - case ignored, default csRGB.
clip: (irect-like) restrict rendering to this area.
alpha: (bool) include alpha channel
annots: (bool) also render annotations
"""
return doc[pno].get_pixmap(
matrix=matrix,
dpi=dpi, colorspace=colorspace,
clip=clip,
alpha=alpha,
annots=annots
)
def getLinkDict(ln, document=None) -> dict:
if isinstance(ln, fitz.Outline):
dest = ln.destination(document)
elif isinstance(ln, fitz.Link):
dest = ln.dest
else:
assert 0, f'Unexpected {type(ln)=}.'
nl = {"kind": dest.kind, "xref": 0}
try:
nl["from"] = ln.rect
except Exception:
if g_exceptions_verbose: fitz.exception_info()
pass
pnt = fitz.Point(0, 0)
if dest.flags & fitz.LINK_FLAG_L_VALID:
pnt.x = dest.lt.x
if dest.flags & fitz.LINK_FLAG_T_VALID:
pnt.y = dest.lt.y
if dest.kind == fitz.LINK_URI:
nl["uri"] = dest.uri
elif dest.kind == fitz.LINK_GOTO:
nl["page"] = dest.page
nl["to"] = pnt
if dest.flags & fitz.LINK_FLAG_R_IS_ZOOM:
nl["zoom"] = dest.rb.x
else:
nl["zoom"] = 0.0
elif dest.kind == fitz.LINK_GOTOR:
nl["file"] = dest.fileSpec.replace("\\", "/")
nl["page"] = dest.page
if dest.page < 0:
nl["to"] = dest.dest
else:
nl["to"] = pnt
if dest.flags & fitz.LINK_FLAG_R_IS_ZOOM:
nl["zoom"] = dest.rb.x
else:
nl["zoom"] = 0.0
elif dest.kind == fitz.LINK_LAUNCH:
nl["file"] = dest.fileSpec.replace("\\", "/")
elif dest.kind == fitz.LINK_NAMED:
assert not (dest.named.keys() & nl.keys())
nl.update(dest.named)
nl['to'] = fitz.Point(nl['to'])
else:
nl["page"] = dest.page
return nl
def get_links(page: fitz.Page) -> list:
"""Create a list of all links contained in a PDF page.
Notes:
see PyMuPDF ducmentation for details.
"""
fitz.CheckParent(page)
ln = page.first_link
links = []
while ln:
nl = getLinkDict(ln, page.parent)
links.append(nl)
ln = ln.next
if links != [] and page.parent.is_pdf:
from . import extra
linkxrefs = [x for x in
extra.JM_get_annot_xref_list2(page)
if x[1] == fitz.PDF_ANNOT_LINK
]
if len(linkxrefs) == len(links):
for i in range(len(linkxrefs)):
links[i]["xref"] = linkxrefs[i][0]
links[i]["id"] = linkxrefs[i][2]
return links
def get_toc(
doc: fitz.Document,
simple: bool = True,
) -> list:
"""Create a table of contents.
Args:
simple: a bool to control output. Returns a list, where each entry consists of outline level, title, page number and link destination (if simple = False). For details see PyMuPDF's documentation.
"""
def recurse(olItem, liste, lvl):
"""Recursively follow the outline item chain and record item information in a list."""
while olItem and olItem.this.m_internal:
if olItem.title:
title = olItem.title
else:
title = " "
if not olItem.is_external:
if olItem.uri:
if olItem.page == -1:
resolve = doc.resolve_link(olItem.uri)
page = resolve[0] + 1
else:
page = olItem.page + 1
else:
page = -1
else:
page = -1
if not simple:
link = getLinkDict(olItem, doc)
liste.append([lvl, title, page, link])
else:
liste.append([lvl, title, page])
if olItem.down:
liste = recurse(olItem.down, liste, lvl + 1)
olItem = olItem.next
return liste
if doc.is_closed:
raise ValueError("document closed")
doc.init_doc()
olItem = doc.outline
if not olItem:
return []
lvl = 1
liste = []
toc = recurse(olItem, liste, lvl)
if doc.is_pdf and simple is False:
doc._extend_toc_items(toc)
return toc
def del_toc_item(
doc: fitz.Document,
idx: int,
) -> None:
"""Delete TOC / bookmark item by index."""
xref = doc.get_outline_xrefs()[idx]
doc._remove_toc_item(xref)
def set_toc_item(
doc: fitz.Document,
idx: int,
dest_dict: OptDict = None,
kind: OptInt = None,
pno: OptInt = None,
uri: OptStr = None,
title: OptStr = None,
to: point_like = None,
filename: OptStr = None,
zoom: float = 0,
) -> None:
"""Update TOC item by index.
It allows changing the item's title and link destination.
Args:
idx: (int) desired index of the TOC list, as created by get_toc.
dest_dict: (dict) destination dictionary as created by get_toc(False).
Outrules all other parameters. If None, the remaining parameters
are used to make a dest dictionary.
kind: (int) kind of link (fitz.LINK_GOTO, etc.). If None, then only the
title will be updated. If fitz.LINK_NONE, the TOC item will be deleted.
pno: (int) page number (1-based like in get_toc). Required if fitz.LINK_GOTO.
uri: (str) the URL, required if fitz.LINK_URI.
title: (str) the new title. No change if None.
to: (point-like) destination on the target page. If omitted, (72, 36)
will be used as taget coordinates.
filename: (str) destination filename, required for fitz.LINK_GOTOR and
fitz.LINK_LAUNCH.
name: (str) a destination name for fitz.LINK_NAMED.
zoom: (float) a zoom factor for the target location (fitz.LINK_GOTO).
"""
xref = doc.get_outline_xrefs()[idx]
page_xref = 0
if type(dest_dict) is dict:
if dest_dict["kind"] == fitz.LINK_GOTO:
pno = dest_dict["page"]
page_xref = doc.page_xref(pno)
page_height = doc.page_cropbox(pno).height
to = dest_dict.get('to', fitz.Point(72, 36))
to.y = page_height - to.y
dest_dict["to"] = to
action = getDestStr(page_xref, dest_dict)
if not action.startswith("/A"):
raise ValueError("bad bookmark dest")
color = dest_dict.get("color")
if color:
color = list(map(float, color))
if len(color) != 3 or min(color) < 0 or max(color) > 1:
raise ValueError("bad color value")
bold = dest_dict.get("bold", False)
italic = dest_dict.get("italic", False)
flags = italic + 2 * bold
collapse = dest_dict.get("collapse")
return doc._update_toc_item(
xref,
action=action[2:],
title=title,
color=color,
flags=flags,
collapse=collapse,
)
if kind == fitz.LINK_NONE:
return doc.del_toc_item(idx)
if kind is None and title is None:
return None
if kind is None:
return doc._update_toc_item(xref, action=None, title=title)
if kind == fitz.LINK_GOTO:
if pno is None or pno not in range(1, doc.page_count + 1):
raise ValueError("bad page number")
page_xref = doc.page_xref(pno - 1)
page_height = doc.page_cropbox(pno - 1).height
if to is None:
to = fitz.Point(72, page_height - 36)
else:
to = fitz.Point(to)
to.y = page_height - to.y
ddict = {
"kind": kind,
"to": to,
"uri": uri,
"page": pno,
"file": filename,
"zoom": zoom,
}
action = getDestStr(page_xref, ddict)
if action == "" or not action.startswith("/A"):
raise ValueError("bad bookmark dest")
return doc._update_toc_item(xref, action=action[2:], title=title)
def get_area(*args) -> float:
"""Calculate area of rectangle.\nparameter is one of 'px' (default), 'in', 'cm', or 'mm'."""
rect = args[0]
if len(args) > 1:
unit = args[1]
else:
unit = "px"
u = {"px": (1, 1), "in": (1.0, 72.0), "cm": (2.54, 72.0), "mm": (25.4, 72.0)}
f = (u[unit][0] / u[unit][1]) ** 2
return f * rect.width * rect.height
def set_metadata(doc: fitz.Document, m: dict) -> None:
"""Update the PDF /Info object.
Args:
m: a dictionary like doc.metadata.
"""
if not doc.is_pdf:
raise ValueError("is no PDF")
if doc.is_closed or doc.is_encrypted:
raise ValueError("document closed or encrypted")
if type(m) is not dict:
raise ValueError("bad metadata")
keymap = {
"author": "Author",
"producer": "Producer",
"creator": "Creator",
"title": "Title",
"format": None,
"encryption": None,
"creationDate": "CreationDate",
"modDate": "ModDate",
"subject": "Subject",
"keywords": "Keywords",
"trapped": "Trapped",
}
valid_keys = set(keymap.keys())
diff_set = set(m.keys()).difference(valid_keys)
if diff_set != set():
msg = "bad dict key(s): %s" % diff_set
raise ValueError(msg)
t, temp = doc.xref_get_key(-1, "Info")
if t != "xref":
info_xref = 0
else:
info_xref = int(temp.replace("0 R", ""))
if m == {} and info_xref == 0:
return
if info_xref == 0:
info_xref = doc.get_new_xref()
doc.update_object(info_xref, "<<>>")
doc.xref_set_key(-1, "Info", "%i 0 R" % info_xref)
elif m == {}:
doc.xref_set_key(-1, "Info", "null")
return
for key, val in [(k, v) for k, v in m.items() if keymap[k] is not None]:
pdf_key = keymap[key]
if not bool(val) or val in ("none", "null"):
val = "null"
else:
val = fitz.get_pdf_str(val)
doc.xref_set_key(info_xref, pdf_key, val)
doc.init_doc()
return
def getDestStr(xref: int, ddict: dict) -> str:
"""Calculate the PDF action string.
Notes:
Supports Link annotations and outline items (bookmarks).
"""
if not ddict:
return ""
str_goto = "/A<</S/GoTo/D[%i 0 R/XYZ %g %g %g]>>"
str_gotor1 = "/A<</S/GoToR/D[%s /XYZ %g %g %g]/F<</F%s/UF%s/Type/Filespec>>>>"
str_gotor2 = "/A<</S/GoToR/D%s/F<</F%s/UF%s/Type/Filespec>>>>"
str_launch = "/A<</S/Launch/F<</F%s/UF%s/Type/Filespec>>>>"
str_uri = "/A<</S/URI/URI%s>>"
if type(ddict) in (int, float):
dest = str_goto % (xref, 0, ddict, 0)
return dest
d_kind = ddict.get("kind", fitz.LINK_NONE)
if d_kind == fitz.LINK_NONE:
return ""
if ddict["kind"] == fitz.LINK_GOTO:
d_zoom = ddict.get("zoom", 0)
to = ddict.get("to", fitz.Point(0, 0))
d_left, d_top = to
dest = str_goto % (xref, d_left, d_top, d_zoom)
return dest
if ddict["kind"] == fitz.LINK_URI:
dest = str_uri % (fitz.get_pdf_str(ddict["uri"]),)
return dest
if ddict["kind"] == fitz.LINK_LAUNCH:
fspec = fitz.get_pdf_str(ddict["file"])
dest = str_launch % (fspec, fspec)
return dest
if ddict["kind"] == fitz.LINK_GOTOR and ddict["page"] < 0:
fspec = fitz.get_pdf_str(ddict["file"])
dest = str_gotor2 % (fitz.get_pdf_str(ddict["to"]), fspec, fspec)
return dest
if ddict["kind"] == fitz.LINK_GOTOR and ddict["page"] >= 0:
fspec = fitz.get_pdf_str(ddict["file"])
dest = str_gotor1 % (
ddict["page"],
ddict["to"].x,
ddict["to"].y,
ddict["zoom"],
fspec,
fspec,
)
return dest
return ""
def set_toc(
doc: fitz.Document,
toc: list,
collapse: int = 1,
) -> int:
"""Create new outline tree (table of contents, TOC).
Args:
toc: (list, tuple) each entry must contain level, title, page and
optionally top margin on the page. None or '()' remove the TOC.
collapse: (int) collapses entries beyond this level. Zero or None
shows all entries unfolded.
Returns:
the number of inserted items, or the number of removed items respectively.
"""
if doc.is_closed or doc.is_encrypted:
raise ValueError("document closed or encrypted")
if not doc.is_pdf:
raise ValueError("is no PDF")
if not toc:
return len(doc._delToC())
if type(toc) not in (list, tuple):
raise ValueError("'toc' must be list or tuple")
toclen = len(toc)
page_count = doc.page_count
t0 = toc[0]
if type(t0) not in (list, tuple):
raise ValueError("items must be sequences of 3 or 4 items")
if t0[0] != 1:
raise ValueError("hierarchy level of item 0 must be 1")
for i in list(range(toclen - 1)):
t1 = toc[i]
t2 = toc[i + 1]
if not -1 <= t1[2] <= page_count:
raise ValueError("row %i: page number out of range" % i)
if (type(t2) not in (list, tuple)) or len(t2) not in (3, 4):
raise ValueError("bad row %i" % (i + 1))
if (type(t2[0]) is not int) or t2[0] < 1:
raise ValueError("bad hierarchy level in row %i" % (i + 1))
if t2[0] > t1[0] + 1:
raise ValueError("bad hierarchy level in row %i" % (i + 1))
old_xrefs = doc._delToC()
old_xrefs = []
xref = [0] + old_xrefs
xref[0] = doc._getOLRootNumber()
if toclen > len(old_xrefs):
for i in range((toclen - len(old_xrefs))):
xref.append(doc.get_new_xref())
lvltab = {0: 0}
olitems = [{"count": 0, "first": -1, "last": -1, "xref": xref[0]}]
for i in range(toclen):
o = toc[i]
lvl = o[0]
title = fitz.get_pdf_str(o[1])
pno = min(doc.page_count - 1, max(0, o[2] - 1))
page_xref = doc.page_xref(pno)
page_height = doc.page_cropbox(pno).height
top = fitz.Point(72, page_height - 36)
dest_dict = {"to": top, "kind": fitz.LINK_GOTO}
if o[2] < 0:
dest_dict["kind"] = fitz.LINK_NONE
if len(o) > 3:
if type(o[3]) in (int, float):
dest_dict["to"] = fitz.Point(72, page_height - o[3])
else:
dest_dict = o[3].copy() if type(o[3]) is dict else dest_dict
if "to" not in dest_dict:
dest_dict["to"] = top
else:
point = fitz.Point(dest_dict["to"])
point.y = page_height - point.y
dest_dict["to"] = (point.x, point.y)
d = {}
d["first"] = -1
d["count"] = 0
d["last"] = -1
d["prev"] = -1
d["next"] = -1
d["dest"] = getDestStr(page_xref, dest_dict)
d["top"] = dest_dict["to"]
d["title"] = title
d["parent"] = lvltab[lvl - 1]
d["xref"] = xref[i + 1]
d["color"] = dest_dict.get("color")
d["flags"] = dest_dict.get("italic", 0) + 2 * dest_dict.get("bold", 0)
lvltab[lvl] = i + 1
parent = olitems[lvltab[lvl - 1]]
if (
dest_dict.get("collapse") or collapse and lvl > collapse
):
parent["count"] -= 1
else:
parent["count"] += 1
if parent["first"] == -1:
parent["first"] = i + 1
parent["last"] = i + 1
else:
d["prev"] = parent["last"]
prev = olitems[parent["last"]]
prev["next"] = i + 1
parent["last"] = i + 1
olitems.append(d)
for i, ol in enumerate(olitems):
txt = "<<"
if ol["count"] != 0:
txt += "/Count %i" % ol["count"]
try:
txt += ol["dest"]
except Exception:
if g_exceptions_verbose: fitz.exception_info()
pass
try:
if ol["first"] > -1:
txt += "/First %i 0 R" % xref[ol["first"]]
except Exception:
if g_exceptions_verbose: fitz.exception_info()
pass
try:
if ol["last"] > -1:
txt += "/Last %i 0 R" % xref[ol["last"]]
except Exception:
if g_exceptions_verbose: fitz.exception_info()
pass
try:
if ol["next"] > -1:
txt += "/Next %i 0 R" % xref[ol["next"]]
except Exception:
if g_exceptions_verbose: fitz.exception_info()
pass
try:
if ol["parent"] > -1:
txt += "/Parent %i 0 R" % xref[ol["parent"]]
except Exception:
if g_exceptions_verbose: fitz.exception_info()
pass
try:
if ol["prev"] > -1:
txt += "/Prev %i 0 R" % xref[ol["prev"]]
except Exception:
if g_exceptions_verbose: fitz.exception_info()
pass
try:
txt += "/Title" + ol["title"]
except Exception:
if g_exceptions_verbose: fitz.exception_info()
pass
if ol.get("color") and len(ol["color"]) == 3:
txt += "/C[ %g %g %g]" % tuple(ol["color"])
if ol.get("flags", 0) > 0:
txt += "/F %i" % ol["flags"]
if i == 0:
txt += "/Type/Outlines"
txt += ">>"
doc.update_object(xref[i], txt)
doc.init_doc()
return toclen
def do_links(
doc1: fitz.Document,
doc2: fitz.Document,
from_page: int = -1,
to_page: int = -1,
start_at: int = -1,
) -> None:
"""Insert links contained in copied page range into destination PDF.
Parameter values **must** equal those of method insert_pdf(), which must
have been previously executed.
"""
def cre_annot(lnk, xref_dst, pno_src, ctm):
"""Create annotation object string for a passed-in link."""
r = lnk["from"] * ctm
rect = "%g %g %g %g" % tuple(r)
if lnk["kind"] == fitz.LINK_GOTO:
txt = fitz.annot_skel["goto1"]
idx = pno_src.index(lnk["page"])
p = lnk["to"] * ctm
annot = txt % (xref_dst[idx], p.x, p.y, lnk["zoom"], rect)
elif lnk["kind"] == fitz.LINK_GOTOR:
if lnk["page"] >= 0:
txt = fitz.annot_skel["gotor1"]
pnt = lnk.get("to", fitz.Point(0, 0))
if type(pnt) is not fitz.Point:
pnt = fitz.Point(0, 0)
annot = txt % (
lnk["page"],
pnt.x,
pnt.y,
lnk["zoom"],
lnk["file"],
lnk["file"],
rect,
)
else:
txt = fitz.annot_skel["gotor2"]
to = fitz.get_pdf_str(lnk["to"])
to = to[1:-1]
f = lnk["file"]
annot = txt % (to, f, rect)
elif lnk["kind"] == fitz.LINK_LAUNCH:
txt = fitz.annot_skel["launch"]
annot = txt % (lnk["file"], lnk["file"], rect)
elif lnk["kind"] == fitz.LINK_URI:
txt = fitz.annot_skel["uri"]
annot = txt % (lnk["uri"], rect)
else:
annot = ""
return annot
if from_page < 0:
fp = 0
elif from_page >= doc2.page_count:
fp = doc2.page_count - 1
else:
fp = from_page
if to_page < 0 or to_page >= doc2.page_count:
tp = doc2.page_count - 1
else:
tp = to_page
if start_at < 0:
raise ValueError("'start_at' must be >= 0")
sa = start_at
incr = 1 if fp <= tp else -1
pno_src = list(range(fp, tp + incr, incr))
pno_dst = [sa + i for i in range(len(pno_src))]
xref_src = []
xref_dst = []
for i in range(len(pno_src)):
p_src = pno_src[i]
p_dst = pno_dst[i]
old_xref = doc2.page_xref(p_src)
new_xref = doc1.page_xref(p_dst)
xref_src.append(old_xref)
xref_dst.append(new_xref)
for i in range(len(xref_src)):
page_src = doc2[pno_src[i]]
links = page_src.get_links()
if len(links) == 0:
page_src = None
continue
ctm = ~page_src.transformation_matrix
page_dst = doc1[pno_dst[i]]
link_tab = []
for l in links:
if l["kind"] == fitz.LINK_GOTO and (l["page"] not in pno_src):
continue
annot_text = cre_annot(l, xref_dst, pno_src, ctm)
if annot_text:
link_tab.append(annot_text)
if link_tab != []:
page_dst._addAnnot_FromString( tuple(link_tab))
return
def getLinkText(page: fitz.Page, lnk: dict) -> str:
ctm = page.transformation_matrix
ictm = ~ctm
r = lnk["from"]
rect = "%g %g %g %g" % tuple(r * ictm)
annot = ""
if lnk["kind"] == fitz.LINK_GOTO:
if lnk["page"] >= 0:
txt = fitz.annot_skel["goto1"]
pno = lnk["page"]
xref = page.parent.page_xref(pno)
pnt = lnk.get("to", fitz.Point(0, 0))
ipnt = pnt * ictm
annot = txt % (xref, ipnt.x, ipnt.y, lnk.get("zoom", 0), rect)
else:
txt = fitz.annot_skel["goto2"]
annot = txt % (fitz.get_pdf_str(lnk["to"]), rect)
elif lnk["kind"] == fitz.LINK_GOTOR:
if lnk["page"] >= 0:
txt = fitz.annot_skel["gotor1"]
pnt = lnk.get("to", fitz.Point(0, 0))
if type(pnt) is not fitz.Point:
pnt = fitz.Point(0, 0)
annot = txt % (
lnk["page"],
pnt.x,
pnt.y,
lnk.get("zoom", 0),
lnk["file"],
lnk["file"],
rect,
)
else:
txt = fitz.annot_skel["gotor2"]
annot = txt % (fitz.get_pdf_str(lnk["to"]), lnk["file"], rect)
elif lnk["kind"] == fitz.LINK_LAUNCH:
txt = fitz.annot_skel["launch"]
annot = txt % (lnk["file"], lnk["file"], rect)
elif lnk["kind"] == fitz.LINK_URI:
txt = fitz.annot_skel["uri"]
annot = txt % (lnk["uri"], rect)
elif lnk["kind"] == fitz.LINK_NAMED:
txt = fitz.annot_skel["named"]
annot = txt % (lnk["name"], rect)
if not annot:
return annot
link_names = dict(
[(x[0], x[2]) for x in page.annot_xrefs() if x[1] == fitz.PDF_ANNOT_LINK]
)
old_name = lnk.get("id", "")
if old_name and (lnk["xref"], old_name) in link_names.items():
name = old_name
else:
i = 0
stem = fitz.TOOLS.set_annot_stem() + "-L%i"
while True:
name = stem % i
if name not in link_names.values():
break
i += 1
annot = annot.replace("/Link", "/Link/NM(%s)" % name)
return annot
def delete_widget(page: fitz.Page, widget: fitz.Widget) -> fitz.Widget:
"""Delete widget from page and return the next one."""
fitz.CheckParent(page)
annot = getattr(widget, "_annot", None)
if annot is None:
raise ValueError("bad type: widget")
nextwidget = widget.next
page.delete_annot(annot)
widget._annot.__del__()
widget._annot.parent = None
keylist = list(widget.__dict__.keys())
for key in keylist:
del widget.__dict__[key]
return nextwidget
def update_link(page: fitz.Page, lnk: dict) -> None:
"""Update a link on the current page."""
fitz.CheckParent(page)
annot = getLinkText(page, lnk)
if annot == "":
raise ValueError("link kind not supported")
page.parent.update_object(lnk["xref"], annot, page=page)
return
def insert_link(page: fitz.Page, lnk: dict, mark: bool = True) -> None:
"""Insert a new link for the current page."""
fitz.CheckParent(page)
annot = getLinkText(page, lnk)
if annot == "":
raise ValueError("link kind not supported")
page._addAnnot_FromString((annot,))
return
def insert_textbox(
page: fitz.Page,
rect: rect_like,
buffer: typing.Union[str, list],
fontname: str = "helv",
fontfile: OptStr = None,
set_simple: int = 0,
encoding: int = 0,
fontsize: float = 11,
lineheight: OptFloat = None,
color: OptSeq = None,
fill: OptSeq = None,
expandtabs: int = 1,
align: int = 0,
rotate: int = 0,
render_mode: int = 0,
border_width: float = 0.05,
morph: OptSeq = None,
overlay: bool = True,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> float:
"""Insert text into a given rectangle.
Notes:
Creates a Shape object, uses its same-named method and commits it.
Parameters:
rect: (rect-like) area to use for text.
buffer: text to be inserted
fontname: a Base-14 font, font name or '/name'
fontfile: name of a font file
fontsize: font size
lineheight: overwrite the font property
color: RGB color triple
expandtabs: handles tabulators with string function
align: left, center, right, justified
rotate: 0, 90, 180, or 270 degrees
morph: morph box with a matrix and a fixpoint
overlay: put text in foreground or background
Returns:
unused or deficit rectangle area (float)
"""
img = page.new_shape()
rc = img.insert_textbox(
rect,
buffer,
fontsize=fontsize,
lineheight=lineheight,
fontname=fontname,
fontfile=fontfile,
set_simple=set_simple,
encoding=encoding,
color=color,
fill=fill,
expandtabs=expandtabs,
render_mode=render_mode,
border_width=border_width,
align=align,
rotate=rotate,
morph=morph,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
if rc >= 0:
img.commit(overlay)
return rc
def insert_text(
page: fitz.Page,
point: point_like,
text: typing.Union[str, list],
fontsize: float = 11,
lineheight: OptFloat = None,
fontname: str = "helv",
fontfile: OptStr = None,
set_simple: int = 0,
encoding: int = 0,
color: OptSeq = None,
fill: OptSeq = None,
border_width: float = 0.05,
render_mode: int = 0,
rotate: int = 0,
morph: OptSeq = None,
overlay: bool = True,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
):
img = page.new_shape()
rc = img.insert_text(
point,
text,
fontsize=fontsize,
lineheight=lineheight,
fontname=fontname,
fontfile=fontfile,
set_simple=set_simple,
encoding=encoding,
color=color,
fill=fill,
border_width=border_width,
render_mode=render_mode,
rotate=rotate,
morph=morph,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
if rc >= 0:
img.commit(overlay)
return rc
def new_page(
doc: fitz.Document,
pno: int = -1,
width: float = 595,
height: float = 842,
) -> fitz.Page:
"""Create and return a new page object.
Args:
pno: (int) insert before this page. Default: after last page.
width: (float) page width in points. Default: 595 (ISO A4 width).
height: (float) page height in points. Default 842 (ISO A4 height).
Returns:
A fitz.Page object.
"""
doc._newPage(pno, width=width, height=height)
return doc[pno]
def insert_page(
doc: fitz.Document,
pno: int,
text: typing.Union[str, list, None] = None,
fontsize: float = 11,
width: float = 595,
height: float = 842,
fontname: str = "helv",
fontfile: OptStr = None,
color: OptSeq = (0,),
) -> int:
"""Create a new PDF page and insert some text.
Notes:
Function combining fitz.Document.new_page() and fitz.Page.insert_text().
For parameter details see these methods.
"""
page = doc.new_page(pno=pno, width=width, height=height)
if not bool(text):
return 0
rc = page.insert_text(
(50, 72),
text,
fontsize=fontsize,
fontname=fontname,
fontfile=fontfile,
color=color,
)
return rc
def draw_line(
page: fitz.Page,
p1: point_like,
p2: point_like,
color: OptSeq = (0,),
dashes: OptStr = None,
width: float = 1,
lineCap: int = 0,
lineJoin: int = 0,
overlay: bool = True,
morph: OptSeq = None,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc=0,
) -> fitz.Point:
"""Draw a line from point p1 to point p2."""
img = page.new_shape()
p = img.draw_line(fitz.Point(p1), fitz.Point(p2))
img.finish(
color=color,
dashes=dashes,
width=width,
closePath=False,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return p
def draw_squiggle(
page: fitz.Page,
p1: point_like,
p2: point_like,
breadth: float = 2,
color: OptSeq = (0,),
dashes: OptStr = None,
width: float = 1,
lineCap: int = 0,
lineJoin: int = 0,
overlay: bool = True,
morph: OptSeq = None,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> fitz.Point:
"""Draw a squiggly line from point p1 to point p2."""
img = page.new_shape()
p = img.draw_squiggle(fitz.Point(p1), fitz.Point(p2), breadth=breadth)
img.finish(
color=color,
dashes=dashes,
width=width,
closePath=False,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return p
def draw_zigzag(
page: fitz.Page,
p1: point_like,
p2: point_like,
breadth: float = 2,
color: OptSeq = (0,),
dashes: OptStr = None,
width: float = 1,
lineCap: int = 0,
lineJoin: int = 0,
overlay: bool = True,
morph: OptSeq = None,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> fitz.Point:
"""Draw a zigzag line from point p1 to point p2."""
img = page.new_shape()
p = img.draw_zigzag(fitz.Point(p1), fitz.Point(p2), breadth=breadth)
img.finish(
color=color,
dashes=dashes,
width=width,
closePath=False,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return p
def draw_rect(
page: fitz.Page,
rect: rect_like,
color: OptSeq = (0,),
fill: OptSeq = None,
dashes: OptStr = None,
width: float = 1,
lineCap: int = 0,
lineJoin: int = 0,
morph: OptSeq = None,
overlay: bool = True,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
radius=None,
) -> fitz.Point:
'''
Draw a rectangle. See Shape class method for details.
'''
img = page.new_shape()
Q = img.draw_rect(fitz.Rect(rect), radius=radius)
img.finish(
color=color,
fill=fill,
dashes=dashes,
width=width,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return Q
def draw_quad(
page: fitz.Page,
quad: quad_like,
color: OptSeq = (0,),
fill: OptSeq = None,
dashes: OptStr = None,
width: float = 1,
lineCap: int = 0,
lineJoin: int = 0,
morph: OptSeq = None,
overlay: bool = True,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> fitz.Point:
"""Draw a quadrilateral."""
img = page.new_shape()
Q = img.draw_quad(fitz.Quad(quad))
img.finish(
color=color,
fill=fill,
dashes=dashes,
width=width,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return Q
def draw_polyline(
page: fitz.Page,
points: list,
color: OptSeq = (0,),
fill: OptSeq = None,
dashes: OptStr = None,
width: float = 1,
morph: OptSeq = None,
lineCap: int = 0,
lineJoin: int = 0,
overlay: bool = True,
closePath: bool = False,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> fitz.Point:
"""Draw multiple connected line segments."""
img = page.new_shape()
Q = img.draw_polyline(points)
img.finish(
color=color,
fill=fill,
dashes=dashes,
width=width,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
closePath=closePath,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return Q
def draw_circle(
page: fitz.Page,
center: point_like,
radius: float,
color: OptSeq = (0,),
fill: OptSeq = None,
morph: OptSeq = None,
dashes: OptStr = None,
width: float = 1,
lineCap: int = 0,
lineJoin: int = 0,
overlay: bool = True,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> fitz.Point:
"""Draw a circle given its center and radius."""
img = page.new_shape()
Q = img.draw_circle(fitz.Point(center), radius)
img.finish(
color=color,
fill=fill,
dashes=dashes,
width=width,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return Q
def draw_oval(
page: fitz.Page,
rect: typing.Union[rect_like, quad_like],
color: OptSeq = (0,),
fill: OptSeq = None,
dashes: OptStr = None,
morph: OptSeq = None,
width: float = 1,
lineCap: int = 0,
lineJoin: int = 0,
overlay: bool = True,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> fitz.Point:
"""Draw an oval given its containing rectangle or quad."""
img = page.new_shape()
Q = img.draw_oval(rect)
img.finish(
color=color,
fill=fill,
dashes=dashes,
width=width,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return Q
def draw_curve(
page: fitz.Page,
p1: point_like,
p2: point_like,
p3: point_like,
color: OptSeq = (0,),
fill: OptSeq = None,
dashes: OptStr = None,
width: float = 1,
morph: OptSeq = None,
closePath: bool = False,
lineCap: int = 0,
lineJoin: int = 0,
overlay: bool = True,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> fitz.Point:
"""Draw a special Bezier curve from p1 to p3, generating control points on lines p1 to p2 and p2 to p3."""
img = page.new_shape()
Q = img.draw_curve(fitz.Point(p1), fitz.Point(p2), fitz.Point(p3))
img.finish(
color=color,
fill=fill,
dashes=dashes,
width=width,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
closePath=closePath,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return Q
def draw_bezier(
page: fitz.Page,
p1: point_like,
p2: point_like,
p3: point_like,
p4: point_like,
color: OptSeq = (0,),
fill: OptSeq = None,
dashes: OptStr = None,
width: float = 1,
morph: OptStr = None,
closePath: bool = False,
lineCap: int = 0,
lineJoin: int = 0,
overlay: bool = True,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> fitz.Point:
"""Draw a general cubic Bezier curve from p1 to p4 using control points p2 and p3."""
img = page.new_shape()
Q = img.draw_bezier(fitz.Point(p1), fitz.Point(p2), fitz.Point(p3), fitz.Point(p4))
img.finish(
color=color,
fill=fill,
dashes=dashes,
width=width,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
closePath=closePath,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return Q
def draw_sector(
page: fitz.Page,
center: point_like,
point: point_like,
beta: float,
color: OptSeq = (0,),
fill: OptSeq = None,
dashes: OptStr = None,
fullSector: bool = True,
morph: OptSeq = None,
width: float = 1,
closePath: bool = False,
lineCap: int = 0,
lineJoin: int = 0,
overlay: bool = True,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> fitz.Point:
"""Draw a circle sector given circle center, one arc end point and the angle of the arc.
Parameters:
center -- center of circle
point -- arc end point
beta -- angle of arc (degrees)
fullSector -- connect arc ends with center
"""
img = page.new_shape()
Q = img.draw_sector(fitz.Point(center), fitz.Point(point), beta, fullSector=fullSector)
img.finish(
color=color,
fill=fill,
dashes=dashes,
width=width,
lineCap=lineCap,
lineJoin=lineJoin,
morph=morph,
closePath=closePath,
stroke_opacity=stroke_opacity,
fill_opacity=fill_opacity,
oc=oc,
)
img.commit(overlay)
return Q
def getColorList() -> list:
"""
Returns a list of just the colour names used by this module.
:rtype: list of strings
"""
return [x[0] for x in getColorInfoList()]
def getColorInfoList() -> list:
"""
Returns the list of colour name/value tuples used by this module.
:rtype: list of tuples
"""
return [
("ALICEBLUE", 240, 248, 255),
("ANTIQUEWHITE", 250, 235, 215),
("ANTIQUEWHITE1", 255, 239, 219),
("ANTIQUEWHITE2", 238, 223, 204),
("ANTIQUEWHITE3", 205, 192, 176),
("ANTIQUEWHITE4", 139, 131, 120),
("AQUAMARINE", 127, 255, 212),
("AQUAMARINE1", 127, 255, 212),
("AQUAMARINE2", 118, 238, 198),
("AQUAMARINE3", 102, 205, 170),
("AQUAMARINE4", 69, 139, 116),
("AZURE", 240, 255, 255),
("AZURE1", 240, 255, 255),
("AZURE2", 224, 238, 238),
("AZURE3", 193, 205, 205),
("AZURE4", 131, 139, 139),
("BEIGE", 245, 245, 220),
("BISQUE", 255, 228, 196),
("BISQUE1", 255, 228, 196),
("BISQUE2", 238, 213, 183),
("BISQUE3", 205, 183, 158),
("BISQUE4", 139, 125, 107),
("BLACK", 0, 0, 0),
("BLANCHEDALMOND", 255, 235, 205),
("BLUE", 0, 0, 255),
("BLUE1", 0, 0, 255),
("BLUE2", 0, 0, 238),
("BLUE3", 0, 0, 205),
("BLUE4", 0, 0, 139),
("BLUEVIOLET", 138, 43, 226),
("BROWN", 165, 42, 42),
("BROWN1", 255, 64, 64),
("BROWN2", 238, 59, 59),
("BROWN3", 205, 51, 51),
("BROWN4", 139, 35, 35),
("BURLYWOOD", 222, 184, 135),
("BURLYWOOD1", 255, 211, 155),
("BURLYWOOD2", 238, 197, 145),
("BURLYWOOD3", 205, 170, 125),
("BURLYWOOD4", 139, 115, 85),
("CADETBLUE", 95, 158, 160),
("CADETBLUE1", 152, 245, 255),
("CADETBLUE2", 142, 229, 238),
("CADETBLUE3", 122, 197, 205),
("CADETBLUE4", 83, 134, 139),
("CHARTREUSE", 127, 255, 0),
("CHARTREUSE1", 127, 255, 0),
("CHARTREUSE2", 118, 238, 0),
("CHARTREUSE3", 102, 205, 0),
("CHARTREUSE4", 69, 139, 0),
("CHOCOLATE", 210, 105, 30),
("CHOCOLATE1", 255, 127, 36),
("CHOCOLATE2", 238, 118, 33),
("CHOCOLATE3", 205, 102, 29),
("CHOCOLATE4", 139, 69, 19),
("COFFEE", 156, 79, 0),
("CORAL", 255, 127, 80),
("CORAL1", 255, 114, 86),
("CORAL2", 238, 106, 80),
("CORAL3", 205, 91, 69),
("CORAL4", 139, 62, 47),
("CORNFLOWERBLUE", 100, 149, 237),
("CORNSILK", 255, 248, 220),
("CORNSILK1", 255, 248, 220),
("CORNSILK2", 238, 232, 205),
("CORNSILK3", 205, 200, 177),
("CORNSILK4", 139, 136, 120),
("CYAN", 0, 255, 255),
("CYAN1", 0, 255, 255),
("CYAN2", 0, 238, 238),
("CYAN3", 0, 205, 205),
("CYAN4", 0, 139, 139),
("DARKBLUE", 0, 0, 139),
("DARKCYAN", 0, 139, 139),
("DARKGOLDENROD", 184, 134, 11),
("DARKGOLDENROD1", 255, 185, 15),
("DARKGOLDENROD2", 238, 173, 14),
("DARKGOLDENROD3", 205, 149, 12),
("DARKGOLDENROD4", 139, 101, 8),
("DARKGREEN", 0, 100, 0),
("DARKGRAY", 169, 169, 169),
("DARKKHAKI", 189, 183, 107),
("DARKMAGENTA", 139, 0, 139),
("DARKOLIVEGREEN", 85, 107, 47),
("DARKOLIVEGREEN1", 202, 255, 112),
("DARKOLIVEGREEN2", 188, 238, 104),
("DARKOLIVEGREEN3", 162, 205, 90),
("DARKOLIVEGREEN4", 110, 139, 61),
("DARKORANGE", 255, 140, 0),
("DARKORANGE1", 255, 127, 0),
("DARKORANGE2", 238, 118, 0),
("DARKORANGE3", 205, 102, 0),
("DARKORANGE4", 139, 69, 0),
("DARKORCHID", 153, 50, 204),
("DARKORCHID1", 191, 62, 255),
("DARKORCHID2", 178, 58, 238),
("DARKORCHID3", 154, 50, 205),
("DARKORCHID4", 104, 34, 139),
("DARKRED", 139, 0, 0),
("DARKSALMON", 233, 150, 122),
("DARKSEAGREEN", 143, 188, 143),
("DARKSEAGREEN1", 193, 255, 193),
("DARKSEAGREEN2", 180, 238, 180),
("DARKSEAGREEN3", 155, 205, 155),
("DARKSEAGREEN4", 105, 139, 105),
("DARKSLATEBLUE", 72, 61, 139),
("DARKSLATEGRAY", 47, 79, 79),
("DARKTURQUOISE", 0, 206, 209),
("DARKVIOLET", 148, 0, 211),
("DEEPPINK", 255, 20, 147),
("DEEPPINK1", 255, 20, 147),
("DEEPPINK2", 238, 18, 137),
("DEEPPINK3", 205, 16, 118),
("DEEPPINK4", 139, 10, 80),
("DEEPSKYBLUE", 0, 191, 255),
("DEEPSKYBLUE1", 0, 191, 255),
("DEEPSKYBLUE2", 0, 178, 238),
("DEEPSKYBLUE3", 0, 154, 205),
("DEEPSKYBLUE4", 0, 104, 139),
("DIMGRAY", 105, 105, 105),
("DODGERBLUE", 30, 144, 255),
("DODGERBLUE1", 30, 144, 255),
("DODGERBLUE2", 28, 134, 238),
("DODGERBLUE3", 24, 116, 205),
("DODGERBLUE4", 16, 78, 139),
("FIREBRICK", 178, 34, 34),
("FIREBRICK1", 255, 48, 48),
("FIREBRICK2", 238, 44, 44),
("FIREBRICK3", 205, 38, 38),
("FIREBRICK4", 139, 26, 26),
("FLORALWHITE", 255, 250, 240),
("FORESTGREEN", 34, 139, 34),
("GAINSBORO", 220, 220, 220),
("GHOSTWHITE", 248, 248, 255),
("GOLD", 255, 215, 0),
("GOLD1", 255, 215, 0),
("GOLD2", 238, 201, 0),
("GOLD3", 205, 173, 0),
("GOLD4", 139, 117, 0),
("GOLDENROD", 218, 165, 32),
("GOLDENROD1", 255, 193, 37),
("GOLDENROD2", 238, 180, 34),
("GOLDENROD3", 205, 155, 29),
("GOLDENROD4", 139, 105, 20),
("GREEN YELLOW", 173, 255, 47),
("GREEN", 0, 255, 0),
("GREEN1", 0, 255, 0),
("GREEN2", 0, 238, 0),
("GREEN3", 0, 205, 0),
("GREEN4", 0, 139, 0),
("GREENYELLOW", 173, 255, 47),
("GRAY", 190, 190, 190),
("GRAY0", 0, 0, 0),
("GRAY1", 3, 3, 3),
("GRAY10", 26, 26, 26),
("GRAY100", 255, 255, 255),
("GRAY11", 28, 28, 28),
("GRAY12", 31, 31, 31),
("GRAY13", 33, 33, 33),
("GRAY14", 36, 36, 36),
("GRAY15", 38, 38, 38),
("GRAY16", 41, 41, 41),
("GRAY17", 43, 43, 43),
("GRAY18", 46, 46, 46),
("GRAY19", 48, 48, 48),
("GRAY2", 5, 5, 5),
("GRAY20", 51, 51, 51),
("GRAY21", 54, 54, 54),
("GRAY22", 56, 56, 56),
("GRAY23", 59, 59, 59),
("GRAY24", 61, 61, 61),
("GRAY25", 64, 64, 64),
("GRAY26", 66, 66, 66),
("GRAY27", 69, 69, 69),
("GRAY28", 71, 71, 71),
("GRAY29", 74, 74, 74),
("GRAY3", 8, 8, 8),
("GRAY30", 77, 77, 77),
("GRAY31", 79, 79, 79),
("GRAY32", 82, 82, 82),
("GRAY33", 84, 84, 84),
("GRAY34", 87, 87, 87),
("GRAY35", 89, 89, 89),
("GRAY36", 92, 92, 92),
("GRAY37", 94, 94, 94),
("GRAY38", 97, 97, 97),
("GRAY39", 99, 99, 99),
("GRAY4", 10, 10, 10),
("GRAY40", 102, 102, 102),
("GRAY41", 105, 105, 105),
("GRAY42", 107, 107, 107),
("GRAY43", 110, 110, 110),
("GRAY44", 112, 112, 112),
("GRAY45", 115, 115, 115),
("GRAY46", 117, 117, 117),
("GRAY47", 120, 120, 120),
("GRAY48", 122, 122, 122),
("GRAY49", 125, 125, 125),
("GRAY5", 13, 13, 13),
("GRAY50", 127, 127, 127),
("GRAY51", 130, 130, 130),
("GRAY52", 133, 133, 133),
("GRAY53", 135, 135, 135),
("GRAY54", 138, 138, 138),
("GRAY55", 140, 140, 140),
("GRAY56", 143, 143, 143),
("GRAY57", 145, 145, 145),
("GRAY58", 148, 148, 148),
("GRAY59", 150, 150, 150),
("GRAY6", 15, 15, 15),
("GRAY60", 153, 153, 153),
("GRAY61", 156, 156, 156),
("GRAY62", 158, 158, 158),
("GRAY63", 161, 161, 161),
("GRAY64", 163, 163, 163),
("GRAY65", 166, 166, 166),
("GRAY66", 168, 168, 168),
("GRAY67", 171, 171, 171),
("GRAY68", 173, 173, 173),
("GRAY69", 176, 176, 176),
("GRAY7", 18, 18, 18),
("GRAY70", 179, 179, 179),
("GRAY71", 181, 181, 181),
("GRAY72", 184, 184, 184),
("GRAY73", 186, 186, 186),
("GRAY74", 189, 189, 189),
("GRAY75", 191, 191, 191),
("GRAY76", 194, 194, 194),
("GRAY77", 196, 196, 196),
("GRAY78", 199, 199, 199),
("GRAY79", 201, 201, 201),
("GRAY8", 20, 20, 20),
("GRAY80", 204, 204, 204),
("GRAY81", 207, 207, 207),
("GRAY82", 209, 209, 209),
("GRAY83", 212, 212, 212),
("GRAY84", 214, 214, 214),
("GRAY85", 217, 217, 217),
("GRAY86", 219, 219, 219),
("GRAY87", 222, 222, 222),
("GRAY88", 224, 224, 224),
("GRAY89", 227, 227, 227),
("GRAY9", 23, 23, 23),
("GRAY90", 229, 229, 229),
("GRAY91", 232, 232, 232),
("GRAY92", 235, 235, 235),
("GRAY93", 237, 237, 237),
("GRAY94", 240, 240, 240),
("GRAY95", 242, 242, 242),
("GRAY96", 245, 245, 245),
("GRAY97", 247, 247, 247),
("GRAY98", 250, 250, 250),
("GRAY99", 252, 252, 252),
("HONEYDEW", 240, 255, 240),
("HONEYDEW1", 240, 255, 240),
("HONEYDEW2", 224, 238, 224),
("HONEYDEW3", 193, 205, 193),
("HONEYDEW4", 131, 139, 131),
("HOTPINK", 255, 105, 180),
("HOTPINK1", 255, 110, 180),
("HOTPINK2", 238, 106, 167),
("HOTPINK3", 205, 96, 144),
("HOTPINK4", 139, 58, 98),
("INDIANRED", 205, 92, 92),
("INDIANRED1", 255, 106, 106),
("INDIANRED2", 238, 99, 99),
("INDIANRED3", 205, 85, 85),
("INDIANRED4", 139, 58, 58),
("IVORY", 255, 255, 240),
("IVORY1", 255, 255, 240),
("IVORY2", 238, 238, 224),
("IVORY3", 205, 205, 193),
("IVORY4", 139, 139, 131),
("KHAKI", 240, 230, 140),
("KHAKI1", 255, 246, 143),
("KHAKI2", 238, 230, 133),
("KHAKI3", 205, 198, 115),
("KHAKI4", 139, 134, 78),
("LAVENDER", 230, 230, 250),
("LAVENDERBLUSH", 255, 240, 245),
("LAVENDERBLUSH1", 255, 240, 245),
("LAVENDERBLUSH2", 238, 224, 229),
("LAVENDERBLUSH3", 205, 193, 197),
("LAVENDERBLUSH4", 139, 131, 134),
("LAWNGREEN", 124, 252, 0),
("LEMONCHIFFON", 255, 250, 205),
("LEMONCHIFFON1", 255, 250, 205),
("LEMONCHIFFON2", 238, 233, 191),
("LEMONCHIFFON3", 205, 201, 165),
("LEMONCHIFFON4", 139, 137, 112),
("LIGHTBLUE", 173, 216, 230),
("LIGHTBLUE1", 191, 239, 255),
("LIGHTBLUE2", 178, 223, 238),
("LIGHTBLUE3", 154, 192, 205),
("LIGHTBLUE4", 104, 131, 139),
("LIGHTCORAL", 240, 128, 128),
("LIGHTCYAN", 224, 255, 255),
("LIGHTCYAN1", 224, 255, 255),
("LIGHTCYAN2", 209, 238, 238),
("LIGHTCYAN3", 180, 205, 205),
("LIGHTCYAN4", 122, 139, 139),
("LIGHTGOLDENROD", 238, 221, 130),
("LIGHTGOLDENROD1", 255, 236, 139),
("LIGHTGOLDENROD2", 238, 220, 130),
("LIGHTGOLDENROD3", 205, 190, 112),
("LIGHTGOLDENROD4", 139, 129, 76),
("LIGHTGOLDENRODYELLOW", 250, 250, 210),
("LIGHTGREEN", 144, 238, 144),
("LIGHTGRAY", 211, 211, 211),
("LIGHTPINK", 255, 182, 193),
("LIGHTPINK1", 255, 174, 185),
("LIGHTPINK2", 238, 162, 173),
("LIGHTPINK3", 205, 140, 149),
("LIGHTPINK4", 139, 95, 101),
("LIGHTSALMON", 255, 160, 122),
("LIGHTSALMON1", 255, 160, 122),
("LIGHTSALMON2", 238, 149, 114),
("LIGHTSALMON3", 205, 129, 98),
("LIGHTSALMON4", 139, 87, 66),
("LIGHTSEAGREEN", 32, 178, 170),
("LIGHTSKYBLUE", 135, 206, 250),
("LIGHTSKYBLUE1", 176, 226, 255),
("LIGHTSKYBLUE2", 164, 211, 238),
("LIGHTSKYBLUE3", 141, 182, 205),
("LIGHTSKYBLUE4", 96, 123, 139),
("LIGHTSLATEBLUE", 132, 112, 255),
("LIGHTSLATEGRAY", 119, 136, 153),
("LIGHTSTEELBLUE", 176, 196, 222),
("LIGHTSTEELBLUE1", 202, 225, 255),
("LIGHTSTEELBLUE2", 188, 210, 238),
("LIGHTSTEELBLUE3", 162, 181, 205),
("LIGHTSTEELBLUE4", 110, 123, 139),
("LIGHTYELLOW", 255, 255, 224),
("LIGHTYELLOW1", 255, 255, 224),
("LIGHTYELLOW2", 238, 238, 209),
("LIGHTYELLOW3", 205, 205, 180),
("LIGHTYELLOW4", 139, 139, 122),
("LIMEGREEN", 50, 205, 50),
("LINEN", 250, 240, 230),
("MAGENTA", 255, 0, 255),
("MAGENTA1", 255, 0, 255),
("MAGENTA2", 238, 0, 238),
("MAGENTA3", 205, 0, 205),
("MAGENTA4", 139, 0, 139),
("MAROON", 176, 48, 96),
("MAROON1", 255, 52, 179),
("MAROON2", 238, 48, 167),
("MAROON3", 205, 41, 144),
("MAROON4", 139, 28, 98),
("MEDIUMAQUAMARINE", 102, 205, 170),
("MEDIUMBLUE", 0, 0, 205),
("MEDIUMORCHID", 186, 85, 211),
("MEDIUMORCHID1", 224, 102, 255),
("MEDIUMORCHID2", 209, 95, 238),
("MEDIUMORCHID3", 180, 82, 205),
("MEDIUMORCHID4", 122, 55, 139),
("MEDIUMPURPLE", 147, 112, 219),
("MEDIUMPURPLE1", 171, 130, 255),
("MEDIUMPURPLE2", 159, 121, 238),
("MEDIUMPURPLE3", 137, 104, 205),
("MEDIUMPURPLE4", 93, 71, 139),
("MEDIUMSEAGREEN", 60, 179, 113),
("MEDIUMSLATEBLUE", 123, 104, 238),
("MEDIUMSPRINGGREEN", 0, 250, 154),
("MEDIUMTURQUOISE", 72, 209, 204),
("MEDIUMVIOLETRED", 199, 21, 133),
("MIDNIGHTBLUE", 25, 25, 112),
("MINTCREAM", 245, 255, 250),
("MISTYROSE", 255, 228, 225),
("MISTYROSE1", 255, 228, 225),
("MISTYROSE2", 238, 213, 210),
("MISTYROSE3", 205, 183, 181),
("MISTYROSE4", 139, 125, 123),
("MOCCASIN", 255, 228, 181),
("MUPDFBLUE", 37, 114, 172),
("NAVAJOWHITE", 255, 222, 173),
("NAVAJOWHITE1", 255, 222, 173),
("NAVAJOWHITE2", 238, 207, 161),
("NAVAJOWHITE3", 205, 179, 139),
("NAVAJOWHITE4", 139, 121, 94),
("NAVY", 0, 0, 128),
("NAVYBLUE", 0, 0, 128),
("OLDLACE", 253, 245, 230),
("OLIVEDRAB", 107, 142, 35),
("OLIVEDRAB1", 192, 255, 62),
("OLIVEDRAB2", 179, 238, 58),
("OLIVEDRAB3", 154, 205, 50),
("OLIVEDRAB4", 105, 139, 34),
("ORANGE", 255, 165, 0),
("ORANGE1", 255, 165, 0),
("ORANGE2", 238, 154, 0),
("ORANGE3", 205, 133, 0),
("ORANGE4", 139, 90, 0),
("ORANGERED", 255, 69, 0),
("ORANGERED1", 255, 69, 0),
("ORANGERED2", 238, 64, 0),
("ORANGERED3", 205, 55, 0),
("ORANGERED4", 139, 37, 0),
("ORCHID", 218, 112, 214),
("ORCHID1", 255, 131, 250),
("ORCHID2", 238, 122, 233),
("ORCHID3", 205, 105, 201),
("ORCHID4", 139, 71, 137),
("PALEGOLDENROD", 238, 232, 170),
("PALEGREEN", 152, 251, 152),
("PALEGREEN1", 154, 255, 154),
("PALEGREEN2", 144, 238, 144),
("PALEGREEN3", 124, 205, 124),
("PALEGREEN4", 84, 139, 84),
("PALETURQUOISE", 175, 238, 238),
("PALETURQUOISE1", 187, 255, 255),
("PALETURQUOISE2", 174, 238, 238),
("PALETURQUOISE3", 150, 205, 205),
("PALETURQUOISE4", 102, 139, 139),
("PALEVIOLETRED", 219, 112, 147),
("PALEVIOLETRED1", 255, 130, 171),
("PALEVIOLETRED2", 238, 121, 159),
("PALEVIOLETRED3", 205, 104, 137),
("PALEVIOLETRED4", 139, 71, 93),
("PAPAYAWHIP", 255, 239, 213),
("PEACHPUFF", 255, 218, 185),
("PEACHPUFF1", 255, 218, 185),
("PEACHPUFF2", 238, 203, 173),
("PEACHPUFF3", 205, 175, 149),
("PEACHPUFF4", 139, 119, 101),
("PERU", 205, 133, 63),
("PINK", 255, 192, 203),
("PINK1", 255, 181, 197),
("PINK2", 238, 169, 184),
("PINK3", 205, 145, 158),
("PINK4", 139, 99, 108),
("PLUM", 221, 160, 221),
("PLUM1", 255, 187, 255),
("PLUM2", 238, 174, 238),
("PLUM3", 205, 150, 205),
("PLUM4", 139, 102, 139),
("POWDERBLUE", 176, 224, 230),
("PURPLE", 160, 32, 240),
("PURPLE1", 155, 48, 255),
("PURPLE2", 145, 44, 238),
("PURPLE3", 125, 38, 205),
("PURPLE4", 85, 26, 139),
("PY_COLOR", 240, 255, 210),
("RED", 255, 0, 0),
("RED1", 255, 0, 0),
("RED2", 238, 0, 0),
("RED3", 205, 0, 0),
("RED4", 139, 0, 0),
("ROSYBROWN", 188, 143, 143),
("ROSYBROWN1", 255, 193, 193),
("ROSYBROWN2", 238, 180, 180),
("ROSYBROWN3", 205, 155, 155),
("ROSYBROWN4", 139, 105, 105),
("ROYALBLUE", 65, 105, 225),
("ROYALBLUE1", 72, 118, 255),
("ROYALBLUE2", 67, 110, 238),
("ROYALBLUE3", 58, 95, 205),
("ROYALBLUE4", 39, 64, 139),
("SADDLEBROWN", 139, 69, 19),
("SALMON", 250, 128, 114),
("SALMON1", 255, 140, 105),
("SALMON2", 238, 130, 98),
("SALMON3", 205, 112, 84),
("SALMON4", 139, 76, 57),
("SANDYBROWN", 244, 164, 96),
("SEAGREEN", 46, 139, 87),
("SEAGREEN1", 84, 255, 159),
("SEAGREEN2", 78, 238, 148),
("SEAGREEN3", 67, 205, 128),
("SEAGREEN4", 46, 139, 87),
("SEASHELL", 255, 245, 238),
("SEASHELL1", 255, 245, 238),
("SEASHELL2", 238, 229, 222),
("SEASHELL3", 205, 197, 191),
("SEASHELL4", 139, 134, 130),
("SIENNA", 160, 82, 45),
("SIENNA1", 255, 130, 71),
("SIENNA2", 238, 121, 66),
("SIENNA3", 205, 104, 57),
("SIENNA4", 139, 71, 38),
("SKYBLUE", 135, 206, 235),
("SKYBLUE1", 135, 206, 255),
("SKYBLUE2", 126, 192, 238),
("SKYBLUE3", 108, 166, 205),
("SKYBLUE4", 74, 112, 139),
("SLATEBLUE", 106, 90, 205),
("SLATEBLUE1", 131, 111, 255),
("SLATEBLUE2", 122, 103, 238),
("SLATEBLUE3", 105, 89, 205),
("SLATEBLUE4", 71, 60, 139),
("SLATEGRAY", 112, 128, 144),
("SNOW", 255, 250, 250),
("SNOW1", 255, 250, 250),
("SNOW2", 238, 233, 233),
("SNOW3", 205, 201, 201),
("SNOW4", 139, 137, 137),
("SPRINGGREEN", 0, 255, 127),
("SPRINGGREEN1", 0, 255, 127),
("SPRINGGREEN2", 0, 238, 118),
("SPRINGGREEN3", 0, 205, 102),
("SPRINGGREEN4", 0, 139, 69),
("STEELBLUE", 70, 130, 180),
("STEELBLUE1", 99, 184, 255),
("STEELBLUE2", 92, 172, 238),
("STEELBLUE3", 79, 148, 205),
("STEELBLUE4", 54, 100, 139),
("TAN", 210, 180, 140),
("TAN1", 255, 165, 79),
("TAN2", 238, 154, 73),
("TAN3", 205, 133, 63),
("TAN4", 139, 90, 43),
("THISTLE", 216, 191, 216),
("THISTLE1", 255, 225, 255),
("THISTLE2", 238, 210, 238),
("THISTLE3", 205, 181, 205),
("THISTLE4", 139, 123, 139),
("TOMATO", 255, 99, 71),
("TOMATO1", 255, 99, 71),
("TOMATO2", 238, 92, 66),
("TOMATO3", 205, 79, 57),
("TOMATO4", 139, 54, 38),
("TURQUOISE", 64, 224, 208),
("TURQUOISE1", 0, 245, 255),
("TURQUOISE2", 0, 229, 238),
("TURQUOISE3", 0, 197, 205),
("TURQUOISE4", 0, 134, 139),
("VIOLET", 238, 130, 238),
("VIOLETRED", 208, 32, 144),
("VIOLETRED1", 255, 62, 150),
("VIOLETRED2", 238, 58, 140),
("VIOLETRED3", 205, 50, 120),
("VIOLETRED4", 139, 34, 82),
("WHEAT", 245, 222, 179),
("WHEAT1", 255, 231, 186),
("WHEAT2", 238, 216, 174),
("WHEAT3", 205, 186, 150),
("WHEAT4", 139, 126, 102),
("WHITE", 255, 255, 255),
("WHITESMOKE", 245, 245, 245),
("YELLOW", 255, 255, 0),
("YELLOW1", 255, 255, 0),
("YELLOW2", 238, 238, 0),
("YELLOW3", 205, 205, 0),
("YELLOW4", 139, 139, 0),
("YELLOWGREEN", 154, 205, 50),
]
def getColorInfoDict() -> dict:
d = {}
for item in getColorInfoList():
d[item[0].lower()] = item[1:]
return d
def getColor(name: str) -> tuple:
"""Retrieve RGB color in PDF format by name.
Returns:
a triple of floats in range 0 to 1. In case of name-not-found, "white" is returned.
"""
try:
c = getColorInfoList()[getColorList().index(name.upper())]
return (c[1] / 255.0, c[2] / 255.0, c[3] / 255.0)
except Exception:
fitz.exception_info()
return (1, 1, 1)
def getColorHSV(name: str) -> tuple:
"""Retrieve the hue, saturation, value triple of a color name.
Returns:
a triple (degree, percent, percent). If not found (-1, -1, -1) is returned.
"""
try:
x = getColorInfoList()[getColorList().index(name.upper())]
except Exception:
if g_exceptions_verbose: fitz.exception_info()
return (-1, -1, -1)
r = x[1] / 255.0
g = x[2] / 255.0
b = x[3] / 255.0
cmax = max(r, g, b)
V = round(cmax * 100, 1)
cmin = min(r, g, b)
delta = cmax - cmin
if delta == 0:
hue = 0
elif cmax == r:
hue = 60.0 * (((g - b) / delta) % 6)
elif cmax == g:
hue = 60.0 * (((b - r) / delta) + 2)
else:
hue = 60.0 * (((r - g) / delta) + 4)
H = int(round(hue))
if cmax == 0:
sat = 0
else:
sat = delta / cmax
S = int(round(sat * 100))
return (H, S, V)
def _get_font_properties(doc: fitz.Document, xref: int) -> tuple:
fontname, ext, stype, buffer = doc.extract_font(xref)
asc = 0.8
dsc = -0.2
if ext == "":
return fontname, ext, stype, asc, dsc
if buffer:
try:
font = fitz.Font(fontbuffer=buffer)
asc = font.ascender
dsc = font.descender
bbox = font.bbox
if asc - dsc < 1:
if bbox.y0 < dsc:
dsc = bbox.y0
asc = 1 - dsc
except Exception:
fitz.exception_info()
asc *= 1.2
dsc *= 1.2
return fontname, ext, stype, asc, dsc
if ext != "n/a":
try:
font = fitz.Font(fontname)
asc = font.ascender
dsc = font.descender
except Exception:
fitz.exception_info()
asc *= 1.2
dsc *= 1.2
else:
asc *= 1.2
dsc *= 1.2
return fontname, ext, stype, asc, dsc
def get_char_widths(
doc: fitz.Document, xref: int, limit: int = 256, idx: int = 0, fontdict: OptDict = None
) -> list:
"""Get list of glyph information of a font.
Notes:
Must be provided by its XREF number. If we already dealt with the
font, it will be recorded in doc.FontInfos. Otherwise we insert an
entry there.
Finally we return the glyphs for the font. This is a list of
(glyph, width) where glyph is an integer controlling the char
appearance, and width is a float controlling the char's spacing:
width * fontsize is the actual space.
For 'simple' fonts, glyph == ord(char) will usually be true.
Exceptions are 'Symbol' and 'ZapfDingbats'. We are providing data for these directly here.
"""
fontinfo = fitz.CheckFontInfo(doc, xref)
if fontinfo is None:
if fontdict is None:
name, ext, stype, asc, dsc = _get_font_properties(doc, xref)
fontdict = {
"name": name,
"type": stype,
"ext": ext,
"ascender": asc,
"descender": dsc,
}
else:
name = fontdict["name"]
ext = fontdict["ext"]
stype = fontdict["type"]
ordering = fontdict["ordering"]
simple = fontdict["simple"]
if ext == "":
raise ValueError("xref is not a font")
if stype in ("Type1", "MMType1", "TrueType"):
simple = True
else:
simple = False
if name in ("Fangti", "Ming"):
ordering = 0
elif name in ("Heiti", "Song"):
ordering = 1
elif name in ("Gothic", "Mincho"):
ordering = 2
elif name in ("Dotum", "Batang"):
ordering = 3
else:
ordering = -1
fontdict["simple"] = simple
if name == "ZapfDingbats":
glyphs = fitz.zapf_glyphs
elif name == "Symbol":
glyphs = fitz.symbol_glyphs
else:
glyphs = None
fontdict["glyphs"] = glyphs
fontdict["ordering"] = ordering
fontinfo = [xref, fontdict]
doc.FontInfos.append(fontinfo)
else:
fontdict = fontinfo[1]
glyphs = fontdict["glyphs"]
simple = fontdict["simple"]
ordering = fontdict["ordering"]
if glyphs is None:
oldlimit = 0
else:
oldlimit = len(glyphs)
mylimit = max(256, limit)
if mylimit <= oldlimit:
return glyphs
if ordering < 0:
glyphs = doc._get_char_widths(
xref, fontdict["name"], fontdict["ext"], fontdict["ordering"], mylimit, idx
)
else:
glyphs = None
fontdict["glyphs"] = glyphs
fontinfo[1] = fontdict
fitz.UpdateFontInfo(doc, fontinfo)
return glyphs
class Shape:
"""Create a new shape."""
@staticmethod
def horizontal_angle(C, P):
"""Return the angle to the horizontal for the connection from C to P.
This uses the arcus sine function and resolves its inherent ambiguity by
looking up in which quadrant vector S = P - C is located.
"""
S = fitz.Point(P - C).unit
alfa = math.asin(abs(S.y))
if S.x < 0:
if S.y <= 0:
alfa = -(math.pi - alfa)
else:
alfa = math.pi - alfa
else:
if S.y >= 0:
pass
else:
alfa = -alfa
return alfa
def __init__(self, page: fitz.Page):
fitz.CheckParent(page)
self.page = page
self.doc = page.parent
if not self.doc.is_pdf:
raise ValueError("is no PDF")
self.height = page.mediabox_size.y
self.width = page.mediabox_size.x
self.x = page.cropbox_position.x
self.y = page.cropbox_position.y
self.pctm = page.transformation_matrix
self.ipctm = ~self.pctm
self.draw_cont = ""
self.text_cont = ""
self.totalcont = ""
self.lastPoint = None
self.rect = None
def updateRect(self, x):
if self.rect is None:
if len(x) == 2:
self.rect = fitz.Rect(x, x)
else:
self.rect = fitz.Rect(x)
else:
if len(x) == 2:
x = fitz.Point(x)
self.rect.x0 = min(self.rect.x0, x.x)
self.rect.y0 = min(self.rect.y0, x.y)
self.rect.x1 = max(self.rect.x1, x.x)
self.rect.y1 = max(self.rect.y1, x.y)
else:
x = fitz.Rect(x)
self.rect.x0 = min(self.rect.x0, x.x0)
self.rect.y0 = min(self.rect.y0, x.y0)
self.rect.x1 = max(self.rect.x1, x.x1)
self.rect.y1 = max(self.rect.y1, x.y1)
def draw_line(self, p1: point_like, p2: point_like) -> fitz.Point:
"""Draw a line between two points."""
p1 = fitz.Point(p1)
p2 = fitz.Point(p2)
if not (self.lastPoint == p1):
self.draw_cont += "%g %g m\n" % fitz.JM_TUPLE(p1 * self.ipctm)
self.lastPoint = p1
self.updateRect(p1)
self.draw_cont += "%g %g l\n" % fitz.JM_TUPLE(p2 * self.ipctm)
self.updateRect(p2)
self.lastPoint = p2
return self.lastPoint
def draw_polyline(self, points: list) -> fitz.Point:
"""Draw several connected line segments."""
for i, p in enumerate(points):
if i == 0:
if not (self.lastPoint == fitz.Point(p)):
self.draw_cont += "%g %g m\n" % fitz.JM_TUPLE(fitz.Point(p) * self.ipctm)
self.lastPoint = fitz.Point(p)
else:
self.draw_cont += "%g %g l\n" % fitz.JM_TUPLE(fitz.Point(p) * self.ipctm)
self.updateRect(p)
self.lastPoint = fitz.Point(points[-1])
return self.lastPoint
def draw_bezier(
self,
p1: point_like,
p2: point_like,
p3: point_like,
p4: point_like,
) -> fitz.Point:
"""Draw a standard cubic Bezier curve."""
p1 = fitz.Point(p1)
p2 = fitz.Point(p2)
p3 = fitz.Point(p3)
p4 = fitz.Point(p4)
if not (self.lastPoint == p1):
self.draw_cont += "%g %g m\n" % fitz.JM_TUPLE(p1 * self.ipctm)
self.draw_cont += "%g %g %g %g %g %g c\n" % fitz.JM_TUPLE(
list(p2 * self.ipctm) + list(p3 * self.ipctm) + list(p4 * self.ipctm)
)
self.updateRect(p1)
self.updateRect(p2)
self.updateRect(p3)
self.updateRect(p4)
self.lastPoint = p4
return self.lastPoint
def draw_oval(self, tetra: typing.Union[quad_like, rect_like]) -> fitz.Point:
"""Draw an ellipse inside a tetrapod."""
if len(tetra) != 4:
raise ValueError("invalid arg length")
if hasattr(tetra[0], "__float__"):
q = fitz.Rect(tetra).quad
else:
q = fitz.Quad(tetra)
mt = q.ul + (q.ur - q.ul) * 0.5
mr = q.ur + (q.lr - q.ur) * 0.5
mb = q.ll + (q.lr - q.ll) * 0.5
ml = q.ul + (q.ll - q.ul) * 0.5
if not (self.lastPoint == ml):
self.draw_cont += "%g %g m\n" % fitz.JM_TUPLE(ml * self.ipctm)
self.lastPoint = ml
self.draw_curve(ml, q.ll, mb)
self.draw_curve(mb, q.lr, mr)
self.draw_curve(mr, q.ur, mt)
self.draw_curve(mt, q.ul, ml)
self.updateRect(q.rect)
self.lastPoint = ml
return self.lastPoint
def draw_circle(self, center: point_like, radius: float) -> fitz.Point:
"""Draw a circle given its center and radius."""
if not radius > fitz.EPSILON:
raise ValueError("radius must be positive")
center = fitz.Point(center)
p1 = center - (radius, 0)
return self.draw_sector(center, p1, 360, fullSector=False)
def draw_curve(
self,
p1: point_like,
p2: point_like,
p3: point_like,
) -> fitz.Point:
"""Draw a curve between points using one control point."""
kappa = 0.55228474983
p1 = fitz.Point(p1)
p2 = fitz.Point(p2)
p3 = fitz.Point(p3)
k1 = p1 + (p2 - p1) * kappa
k2 = p3 + (p2 - p3) * kappa
return self.draw_bezier(p1, k1, k2, p3)
def draw_sector(
self,
center: point_like,
point: point_like,
beta: float,
fullSector: bool = True,
) -> fitz.Point:
"""Draw a circle sector."""
center = fitz.Point(center)
point = fitz.Point(point)
l3 = "%g %g m\n"
l4 = "%g %g %g %g %g %g c\n"
l5 = "%g %g l\n"
betar = math.radians(-beta)
w360 = math.radians(math.copysign(360, betar)) * (-1)
w90 = math.radians(math.copysign(90, betar))
w45 = w90 / 2
while abs(betar) > 2 * math.pi:
betar += w360
if not (self.lastPoint == point):
self.draw_cont += l3 % fitz.JM_TUPLE(point * self.ipctm)
self.lastPoint = point
Q = fitz.Point(0, 0)
C = center
P = point
S = P - C
rad = abs(S)
if not rad > fitz.EPSILON:
raise ValueError("radius must be positive")
alfa = self.horizontal_angle(center, point)
while abs(betar) > abs(w90):
q1 = C.x + math.cos(alfa + w90) * rad
q2 = C.y + math.sin(alfa + w90) * rad
Q = fitz.Point(q1, q2)
r1 = C.x + math.cos(alfa + w45) * rad / math.cos(w45)
r2 = C.y + math.sin(alfa + w45) * rad / math.cos(w45)
R = fitz.Point(r1, r2)
kappah = (1 - math.cos(w45)) * 4 / 3 / abs(R - Q)
kappa = kappah * abs(P - Q)
cp1 = P + (R - P) * kappa
cp2 = Q + (R - Q) * kappa
self.draw_cont += l4 % fitz.JM_TUPLE(
list(cp1 * self.ipctm) + list(cp2 * self.ipctm) + list(Q * self.ipctm)
)
betar -= w90
alfa += w90
P = Q
if abs(betar) > 1e-3:
beta2 = betar / 2
q1 = C.x + math.cos(alfa + betar) * rad
q2 = C.y + math.sin(alfa + betar) * rad
Q = fitz.Point(q1, q2)
r1 = C.x + math.cos(alfa + beta2) * rad / math.cos(beta2)
r2 = C.y + math.sin(alfa + beta2) * rad / math.cos(beta2)
R = fitz.Point(r1, r2)
kappah = (1 - math.cos(beta2)) * 4 / 3 / abs(R - Q)
kappa = kappah * abs(P - Q) / (1 - math.cos(betar))
cp1 = P + (R - P) * kappa
cp2 = Q + (R - Q) * kappa
self.draw_cont += l4 % fitz.JM_TUPLE(
list(cp1 * self.ipctm) + list(cp2 * self.ipctm) + list(Q * self.ipctm)
)
if fullSector:
self.draw_cont += l3 % fitz.JM_TUPLE(point * self.ipctm)
self.draw_cont += l5 % fitz.JM_TUPLE(center * self.ipctm)
self.draw_cont += l5 % fitz.JM_TUPLE(Q * self.ipctm)
self.lastPoint = Q
return self.lastPoint
def draw_rect(self, rect: rect_like, *, radius=None) -> fitz.Point:
"""Draw a rectangle.
Args:
radius: if not None, the rectangle will have rounded corners.
This is the radius of the curvature, given as percentage of
the rectangle width or height. Valid are values 0 < v <= 0.5.
For a sequence of two values, the corners will have different
radii. Otherwise, the percentage will be computed from the
shorter side. A value of (0.5, 0.5) will draw an ellipse.
"""
r = fitz.Rect(rect)
if radius is None:
self.draw_cont += "%g %g %g %g re\n" % fitz.JM_TUPLE(
list(r.bl * self.ipctm) + [r.width, r.height]
)
self.updateRect(r)
self.lastPoint = r.tl
return self.lastPoint
if hasattr(radius, "__float__"):
if radius <= 0 or radius > 0.5:
raise ValueError(f"bad radius value {radius}.")
d = min(r.width, r.height) * radius
px = (d, 0)
py = (0, d)
elif hasattr(radius, "__len__") and len(radius) == 2:
rx, ry = radius
px = (rx * r.width, 0)
py = (0, ry * r.height)
if min(rx, ry) <= 0 or max(rx, ry) > 0.5:
raise ValueError(f"bad radius value {radius}.")
else:
raise ValueError(f"bad radius value {radius}.")
lp = self.draw_line(r.tl + py, r.bl - py)
lp = self.draw_curve(lp, r.bl, r.bl + px)
lp = self.draw_line(lp, r.br - px)
lp = self.draw_curve(lp, r.br, r.br - py)
lp = self.draw_line(lp, r.tr + py)
lp = self.draw_curve(lp, r.tr, r.tr - px)
lp = self.draw_line(lp, r.tl + px)
self.lastPoint = self.draw_curve(lp, r.tl, r.tl + py)
self.updateRect(r)
return self.lastPoint
def draw_quad(self, quad: quad_like) -> fitz.Point:
"""Draw a Quad."""
q = fitz.Quad(quad)
return self.draw_polyline([q.ul, q.ll, q.lr, q.ur, q.ul])
def draw_zigzag(
self,
p1: point_like,
p2: point_like,
breadth: float = 2,
) -> fitz.Point:
"""Draw a zig-zagged line from p1 to p2."""
p1 = fitz.Point(p1)
p2 = fitz.Point(p2)
S = p2 - p1
rad = abs(S)
cnt = 4 * int(round(rad / (4 * breadth), 0))
if cnt < 4:
raise ValueError("points too close")
mb = rad / cnt
matrix = fitz.Matrix(fitz.util_hor_matrix(p1, p2))
i_mat = ~matrix
points = []
for i in range(1, cnt):
if i % 4 == 1:
p = fitz.Point(i, -1) * mb
elif i % 4 == 3:
p = fitz.Point(i, 1) * mb
else:
continue
points.append(p * i_mat)
self.draw_polyline([p1] + points + [p2])
return p2
def draw_squiggle(
self,
p1: point_like,
p2: point_like,
breadth=2,
) -> fitz.Point:
"""Draw a squiggly line from p1 to p2."""
p1 = fitz.Point(p1)
p2 = fitz.Point(p2)
S = p2 - p1
rad = abs(S)
cnt = 4 * int(round(rad / (4 * breadth), 0))
if cnt < 4:
raise ValueError("points too close")
mb = rad / cnt
matrix = fitz.Matrix(fitz.util_hor_matrix(p1, p2))
i_mat = ~matrix
k = 2.4142135623765633
points = []
for i in range(1, cnt):
if i % 4 == 1:
p = fitz.Point(i, -k) * mb
elif i % 4 == 3:
p = fitz.Point(i, k) * mb
else:
p = fitz.Point(i, 0) * mb
points.append(p * i_mat)
points = [p1] + points + [p2]
cnt = len(points)
i = 0
while i + 2 < cnt:
self.draw_curve(points[i], points[i + 1], points[i + 2])
i += 2
return p2
def insert_text(
self,
point: point_like,
buffer: typing.Union[str, list],
fontsize: float = 11,
lineheight: OptFloat = None,
fontname: str = "helv",
fontfile: OptStr = None,
set_simple: bool = 0,
encoding: int = 0,
color: OptSeq = None,
fill: OptSeq = None,
render_mode: int = 0,
border_width: float = 0.05,
rotate: int = 0,
morph: OptSeq = None,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> int:
if not bool(buffer):
return 0
if type(buffer) not in (list, tuple):
text = buffer.splitlines()
else:
text = buffer
if not len(text) > 0:
return 0
point = fitz.Point(point)
try:
maxcode = max([ord(c) for c in " ".join(text)])
except Exception:
fitz.exception_info()
return 0
fname = fontname
if fname.startswith("/"):
fname = fname[1:]
xref = self.page.insert_font(
fontname=fname, fontfile=fontfile, encoding=encoding, set_simple=set_simple
)
fontinfo = fitz.CheckFontInfo(self.doc, xref)
fontdict = fontinfo[1]
ordering = fontdict["ordering"]
simple = fontdict["simple"]
bfname = fontdict["name"]
ascender = fontdict["ascender"]
descender = fontdict["descender"]
if lineheight:
lheight = fontsize * lineheight
elif ascender - descender <= 1:
lheight = fontsize * 1.2
else:
lheight = fontsize * (ascender - descender)
if maxcode > 255:
glyphs = self.doc.get_char_widths(xref, maxcode + 1)
else:
glyphs = fontdict["glyphs"]
tab = []
for t in text:
if simple and bfname not in ("Symbol", "ZapfDingbats"):
g = None
else:
g = glyphs
tab.append(fitz.getTJstr(t, g, simple, ordering))
text = tab
color_str = fitz.ColorCode(color, "c")
fill_str = fitz.ColorCode(fill, "f")
if not fill and render_mode == 0:
fill = color
fill_str = fitz.ColorCode(color, "f")
morphing = fitz.CheckMorph(morph)
rot = rotate
if rot % 90 != 0:
raise ValueError("bad rotate value")
while rot < 0:
rot += 360
rot = rot % 360
templ1 = "\nq\n%s%sBT\n%s1 0 0 1 %g %g Tm\n/%s %g Tf "
templ2 = "TJ\n0 -%g TD\n"
cmp90 = "0 1 -1 0 0 0 cm\n"
cmm90 = "0 -1 1 0 0 0 cm\n"
cm180 = "-1 0 0 -1 0 0 cm\n"
height = self.height
width = self.width
if morphing:
m1 = fitz.Matrix(1, 0, 0, 1, morph[0].x + self.x, height - morph[0].y - self.y)
mat = ~m1 * morph[1] * m1
cm = "%g %g %g %g %g %g cm\n" % fitz.JM_TUPLE(mat)
else:
cm = ""
top = height - point.y - self.y
left = point.x + self.x
space = top
if rot == 90:
left = height - point.y - self.y
top = -point.x - self.x
cm += cmp90
space = width - abs(top)
elif rot == 270:
left = -height + point.y + self.y
top = point.x + self.x
cm += cmm90
space = abs(top)
elif rot == 180:
left = -point.x - self.x
top = -height + point.y + self.y
cm += cm180
space = abs(point.y + self.y)
optcont = self.page._get_optional_content(oc)
if optcont is not None:
bdc = "/OC /%s BDC\n" % optcont
emc = "EMC\n"
else:
bdc = emc = ""
alpha = self.page._set_opacity(CA=stroke_opacity, ca=fill_opacity)
if alpha is None:
alpha = ""
else:
alpha = "/%s gs\n" % alpha
nres = templ1 % (bdc, alpha, cm, left, top, fname, fontsize)
if render_mode > 0:
nres += "%i Tr " % render_mode
nres += "%g w " % (border_width * fontsize)
if color is not None:
nres += color_str
if fill is not None:
nres += fill_str
nres += text[0]
nlines = 1
if len(text) > 1:
nres += templ2 % lheight
else:
nres += templ2[:2]
for i in range(1, len(text)):
if space < lheight:
break
if i > 1:
nres += "\nT* "
nres += text[i] + templ2[:2]
space -= lheight
nlines += 1
nres += "\nET\n%sQ\n" % emc
self.text_cont += nres
return nlines
def insert_textbox(
self,
rect: rect_like,
buffer: typing.Union[str, list],
fontname: OptStr = "helv",
fontfile: OptStr = None,
fontsize: float = 11,
lineheight: OptFloat = None,
set_simple: bool = 0,
encoding: int = 0,
color: OptSeq = None,
fill: OptSeq = None,
expandtabs: int = 1,
border_width: float = 0.05,
align: int = 0,
render_mode: int = 0,
rotate: int = 0,
morph: OptSeq = None,
stroke_opacity: float = 1,
fill_opacity: float = 1,
oc: int = 0,
) -> float:
"""Insert text into a given rectangle.
Args:
rect -- the textbox to fill
buffer -- text to be inserted
fontname -- a Base-14 font, font name or '/name'
fontfile -- name of a font file
fontsize -- font size
lineheight -- overwrite the font property
color -- RGB stroke color triple
fill -- RGB fill color triple
render_mode -- text rendering control
border_width -- thickness of glyph borders as percentage of fontsize
expandtabs -- handles tabulators with string function
align -- left, center, right, justified
rotate -- 0, 90, 180, or 270 degrees
morph -- morph box with a matrix and a fixpoint
Returns:
unused or deficit rectangle area (float)
"""
rect = fitz.Rect(rect)
if rect.is_empty or rect.is_infinite:
raise ValueError("text box must be finite and not empty")
color_str = fitz.ColorCode(color, "c")
fill_str = fitz.ColorCode(fill, "f")
if fill is None and render_mode == 0:
fill = color
fill_str = fitz.ColorCode(color, "f")
optcont = self.page._get_optional_content(oc)
if optcont is not None:
bdc = "/OC /%s BDC\n" % optcont
emc = "EMC\n"
else:
bdc = emc = ""
alpha = self.page._set_opacity(CA=stroke_opacity, ca=fill_opacity)
if alpha is None:
alpha = ""
else:
alpha = "/%s gs\n" % alpha
if rotate % 90 != 0:
raise ValueError("rotate must be multiple of 90")
rot = rotate
while rot < 0:
rot += 360
rot = rot % 360
if not bool(buffer):
return rect.height if rot in (0, 180) else rect.width
cmp90 = "0 1 -1 0 0 0 cm\n"
cmm90 = "0 -1 1 0 0 0 cm\n"
cm180 = "-1 0 0 -1 0 0 cm\n"
height = self.height
fname = fontname
if fname.startswith("/"):
fname = fname[1:]
xref = self.page.insert_font(
fontname=fname, fontfile=fontfile, encoding=encoding, set_simple=set_simple
)
fontinfo = fitz.CheckFontInfo(self.doc, xref)
fontdict = fontinfo[1]
ordering = fontdict["ordering"]
simple = fontdict["simple"]
glyphs = fontdict["glyphs"]
bfname = fontdict["name"]
ascender = fontdict["ascender"]
descender = fontdict["descender"]
if lineheight:
lheight_factor = lineheight
elif ascender - descender <= 1:
lheight_factor = 1.2
else:
lheight_factor = ascender - descender
lheight = fontsize * lheight_factor
if type(buffer) in (list, tuple):
t0 = "\n".join(buffer)
else:
t0 = buffer
maxcode = max([ord(c) for c in t0])
if simple and maxcode > 255:
t0 = "".join([c if ord(c) < 256 else "?" for c in t0])
t0 = t0.splitlines()
glyphs = self.doc.get_char_widths(xref, maxcode + 1)
if simple and bfname not in ("Symbol", "ZapfDingbats"):
tj_glyphs = None
else:
tj_glyphs = glyphs
def pixlen(x):
"""Calculate pixel length of x."""
if ordering < 0:
return sum([glyphs[ord(c)][1] for c in x]) * fontsize
else:
return len(x) * fontsize
if ordering < 0:
blen = glyphs[32][1] * fontsize
else:
blen = fontsize
text = ""
if fitz.CheckMorph(morph):
m1 = fitz.Matrix(
1, 0, 0, 1, morph[0].x + self.x, self.height - morph[0].y - self.y
)
mat = ~m1 * morph[1] * m1
cm = "%g %g %g %g %g %g cm\n" % fitz.JM_TUPLE(mat)
else:
cm = ""
progr = 1
c_pnt = fitz.Point(0, fontsize * ascender)
if rot == 0:
point = rect.tl + c_pnt
maxwidth = rect.width
maxheight = rect.height
elif rot == 90:
c_pnt = fitz.Point(fontsize * ascender, 0)
point = rect.bl + c_pnt
maxwidth = rect.height
maxheight = rect.width
cm += cmp90
elif rot == 180:
c_pnt = -fitz.Point(0, fontsize * ascender)
point = rect.br + c_pnt
maxwidth = rect.width
progr = -1
maxheight =rect.height
cm += cm180
else:
c_pnt = -fitz.Point(fontsize * ascender, 0)
point = rect.tr + c_pnt
maxwidth = rect.height
progr = -1
maxheight = rect.width
cm += cmm90
just_tab = []
for i, line in enumerate(t0):
line_t = line.expandtabs(expandtabs).split(" ")
num_words = len(line_t)
lbuff = ""
rest = maxwidth
for j in range(num_words):
word = line_t[j]
pl_w = pixlen(word)
if rest >= pl_w:
lbuff += word + " "
rest -= pl_w + blen
continue
if lbuff:
lbuff = lbuff.rstrip() + "\n"
text += lbuff
just_tab.append(True)
lbuff = ""
rest = maxwidth
if pl_w <= maxwidth:
lbuff = word + " "
rest = maxwidth - pl_w - blen
continue
if len(just_tab) > 0:
just_tab[-1] = False
for c in word:
if pixlen(lbuff) <= maxwidth - pixlen(c):
lbuff += c
else:
lbuff += "\n"
text += lbuff
just_tab.append(False)
lbuff = c
lbuff += " "
rest = maxwidth - pixlen(lbuff)
if lbuff:
text += lbuff.rstrip()
just_tab.append(False)
if i < len(t0) - 1:
text += "\n"
if text.endswith("\n"):
text = text[:-1]
lb_count = text.count("\n") + 1
text_height = lheight * lb_count - descender * fontsize
more = text_height - maxheight
if more > fitz.EPSILON:
return (-1) * more
more = abs(more)
if more < fitz.EPSILON:
more = 0
nres = "\nq\n%s%sBT\n" % (bdc, alpha) + cm
templ = "1 0 0 1 %g %g Tm /%s %g Tf "
text_t = text.splitlines()
just_tab[-1] = False
for i, t in enumerate(text_t):
pl = maxwidth - pixlen(t)
pnt = point + c_pnt * (i * lheight_factor)
if align == 1:
if rot in (0, 180):
pnt = pnt + fitz.Point(pl / 2, 0) * progr
else:
pnt = pnt - fitz.Point(0, pl / 2) * progr
elif align == 2:
if rot in (0, 180):
pnt = pnt + fitz.Point(pl, 0) * progr
else:
pnt = pnt - fitz.Point(0, pl) * progr
elif align == 3:
spaces = t.count(" ")
if spaces > 0 and just_tab[i]:
spacing = pl / spaces
else:
spacing = 0
top = height - pnt.y - self.y
left = pnt.x + self.x
if rot == 90:
left = height - pnt.y - self.y
top = -pnt.x - self.x
elif rot == 270:
left = -height + pnt.y + self.y
top = pnt.x + self.x
elif rot == 180:
left = -pnt.x - self.x
top = -height + pnt.y + self.y
nres += templ % (left, top, fname, fontsize)
if render_mode > 0:
nres += "%i Tr " % render_mode
nres += "%g w " % (border_width * fontsize)
if align == 3:
nres += "%g Tw " % spacing
if color is not None:
nres += color_str
if fill is not None:
nres += fill_str
nres += "%sTJ\n" % fitz.getTJstr(t, tj_glyphs, simple, ordering)
nres += "ET\n%sQ\n" % emc
self.text_cont += nres
self.updateRect(rect)
return more
def finish(
self,
width: float = 1,
color: OptSeq = (0,),
fill: OptSeq = None,
lineCap: int = 0,
lineJoin: int = 0,
dashes: OptStr = None,
even_odd: bool = False,
morph: OptSeq = None,
closePath: bool = True,
fill_opacity: float = 1,
stroke_opacity: float = 1,
oc: int = 0,
) -> None:
"""Finish the current drawing segment.
Notes:
Apply colors, opacity, dashes, line style and width, or
morphing. Also whether to close the path
by connecting last to first point.
"""
if self.draw_cont == "":
return
if width == 0:
color = None
elif color is None:
width = 0
color_str = fitz.ColorCode(color, "c")
fill_str = fitz.ColorCode(fill, "f")
optcont = self.page._get_optional_content(oc)
if optcont is not None:
self.draw_cont = "/OC /%s BDC\n" % optcont + self.draw_cont
emc = "EMC\n"
else:
emc = ""
alpha = self.page._set_opacity(CA=stroke_opacity, ca=fill_opacity)
if alpha is not None:
self.draw_cont = "/%s gs\n" % alpha + self.draw_cont
if width != 1 and width != 0:
self.draw_cont += "%g w\n" % width
if lineCap != 0:
self.draw_cont = "%i J\n" % lineCap + self.draw_cont
if lineJoin != 0:
self.draw_cont = "%i j\n" % lineJoin + self.draw_cont
if dashes not in (None, "", "[] 0"):
self.draw_cont = "%s d\n" % dashes + self.draw_cont
if closePath:
self.draw_cont += "h\n"
self.lastPoint = None
if color is not None:
self.draw_cont += color_str
if fill is not None:
self.draw_cont += fill_str
if color is not None:
if not even_odd:
self.draw_cont += "B\n"
else:
self.draw_cont += "B*\n"
else:
if not even_odd:
self.draw_cont += "f\n"
else:
self.draw_cont += "f*\n"
else:
self.draw_cont += "S\n"
self.draw_cont += emc
if fitz.CheckMorph(morph):
m1 = fitz.Matrix(
1, 0, 0, 1, morph[0].x + self.x, self.height - morph[0].y - self.y
)
mat = ~m1 * morph[1] * m1
self.draw_cont = "%g %g %g %g %g %g cm\n" % fitz.JM_TUPLE(mat) + self.draw_cont
self.totalcont += "\nq\n" + self.draw_cont + "Q\n"
self.draw_cont = ""
self.lastPoint = None
return
def commit(self, overlay: bool = True) -> None:
"""Update the page's /Contents object with Shape data. The argument controls whether data appear in foreground (default) or background."""
fitz.CheckParent(self.page)
self.totalcont += self.text_cont
self.totalcont = self.totalcont.encode()
if self.totalcont != b"":
xref = fitz.TOOLS._insert_contents(self.page, b" ", overlay)
self.doc.update_stream(xref, self.totalcont)
self.lastPoint = None
self.rect = None
self.draw_cont = ""
self.text_cont = ""
self.totalcont = ""
return
def apply_redactions(page: fitz.Page, images: int = 2) -> bool:
"""Apply the redaction annotations of the page.
Args:
page: the PDF page.
images: 0 - ignore images, 1 - remove complete overlapping image,
2 - blank out overlapping image parts.
"""
def center_rect(annot_rect, text, font, fsize):
"""Calculate minimal sub-rectangle for the overlay text.
Notes:
Because 'insert_textbox' supports no vertical text centering,
we calculate an approximate number of lines here and return a
sub-rect with smaller height, which should still be sufficient.
Args:
annot_rect: the annotation rectangle
text: the text to insert.
font: the fontname. Must be one of the CJK or Base-14 set, else
the rectangle is returned unchanged.
fsize: the fontsize
Returns:
A rectangle to use instead of the annot rectangle.
"""
if not text:
return annot_rect
try:
text_width = fitz.get_text_length(text, font, fsize)
except ValueError:
if g_exceptions_verbose: fitz.exception_info()
return annot_rect
line_height = fsize * 1.2
limit = annot_rect.width
h = math.ceil(text_width / limit) * line_height
if h >= annot_rect.height:
return annot_rect
r = annot_rect
y = (annot_rect.tl.y + annot_rect.bl.y - h) * 0.5
r.y0 = y
return r
fitz.CheckParent(page)
doc = page.parent
if doc.is_encrypted or doc.is_closed:
raise ValueError("document closed or encrypted")
if not doc.is_pdf:
raise ValueError("is no PDF")
redact_annots = []
for annot in page.annots(types=(fitz.PDF_ANNOT_REDACT,)):
redact_annots.append(annot._get_redact_values())
if redact_annots == []:
return False
rc = page._apply_redactions(images)
if not rc:
raise ValueError("Error applying redactions.")
shape = page.new_shape()
for redact in redact_annots:
annot_rect = redact["rect"]
fill = redact["fill"]
if fill:
shape.draw_rect(annot_rect)
shape.finish(fill=fill, color=fill)
if "text" in redact.keys():
text = redact["text"]
align = redact.get("align", 0)
fname = redact["fontname"]
fsize = redact["fontsize"]
color = redact["text_color"]
trect = center_rect(annot_rect, text, fname, fsize)
rc = -1
while rc < 0 and fsize >= 4:
rc = shape.insert_textbox(
trect,
text,
fontname=fname,
fontsize=fsize,
color=color,
align=align,
)
fsize -= 0.5
shape.commit()
return True
def scrub(
doc: fitz.Document,
attached_files: bool = True,
clean_pages: bool = True,
embedded_files: bool = True,
hidden_text: bool = True,
javascript: bool = True,
metadata: bool = True,
redactions: bool = True,
redact_images: int = 0,
remove_links: bool = True,
reset_fields: bool = True,
reset_responses: bool = True,
thumbnails: bool = True,
xml_metadata: bool = True,
) -> None:
def remove_hidden(cont_lines):
"""Remove hidden text from a PDF page.
Args:
cont_lines: list of lines with /Contents content. Should have status
from after page.cleanContents().
Returns:
List of /Contents lines from which hidden text has been removed.
Notes:
The input must have been created after the page's /Contents object(s)
have been cleaned with page.cleanContents(). This ensures a standard
formatting: one command per line, single spaces between operators.
This allows for drastic simplification of this code.
"""
out_lines = []
in_text = False
suppress = False
make_return = False
for line in cont_lines:
if line == b"BT":
in_text = True
out_lines.append(line)
continue
if line == b"ET":
in_text = False
out_lines.append(line)
continue
if line == b"3 Tr":
suppress = True
make_return = True
continue
if line[-2:] == b"Tr" and line[0] != b"3":
suppress = False
out_lines.append(line)
continue
if line == b"Q":
suppress = False
out_lines.append(line)
continue
if suppress and in_text:
continue
out_lines.append(line)
if make_return:
return out_lines
else:
return None
if not doc.is_pdf:
raise ValueError("is no PDF")
if doc.is_encrypted or doc.is_closed:
raise ValueError("closed or encrypted doc")
if clean_pages is False:
hidden_text = False
redactions = False
if metadata:
doc.set_metadata({})
for page in doc:
if reset_fields:
for widget in page.widgets():
widget.reset()
if remove_links:
links = page.get_links()
for link in links:
page.delete_link(link)
found_redacts = False
for annot in page.annots():
if annot.type[0] == mupdf.PDF_ANNOT_FILE_ATTACHMENT and attached_files:
annot.update_file(buffer=b" ")
if reset_responses:
annot.delete_responses()
if annot.type[0] == fitz.PDF_ANNOT_REDACT:
found_redacts = True
if redactions and found_redacts:
page.apply_redactions(images=redact_images)
if not (clean_pages or hidden_text):
continue
page.clean_contents()
if not page.get_contents():
continue
if hidden_text:
xref = page.get_contents()[0]
cont = doc.xref_stream(xref)
cont_lines = remove_hidden(cont.splitlines())
if cont_lines:
cont = b"\n".join(cont_lines)
doc.update_stream(xref, cont)
if thumbnails:
if doc.xref_get_key(page.xref, "Thumb")[0] != "null":
doc.xref_set_key(page.xref, "Thumb", "null")
if embedded_files:
for name in doc.embfile_names():
doc.embfile_del(name)
if xml_metadata:
doc.del_xml_metadata()
if not (xml_metadata or javascript):
xref_limit = 0
else:
xref_limit = doc.xref_length()
for xref in range(1, xref_limit):
if not doc.xref_object(xref):
msg = "bad xref %i - clean PDF before scrubbing" % xref
raise ValueError(msg)
if javascript and doc.xref_get_key(xref, "S")[1] == "/JavaScript":
obj = "<</S/JavaScript/JS()>>"
doc.update_object(xref, obj)
continue
if not xml_metadata:
continue
if doc.xref_get_key(xref, "Type")[1] == "/Metadata":
doc.update_object(xref, "<<>>")
doc.update_stream(xref, b"deleted", new=True)
continue
if doc.xref_get_key(xref, "Metadata")[0] != "null":
doc.xref_set_key(xref, "Metadata", "null")
def _show_fz_text( text):
num_spans = 0
num_chars = 0
span = text.m_internal.head
while 1:
if not span:
break
num_spans += 1
num_chars += span.len
span = span.next
return f'num_spans={num_spans} num_chars={num_chars}'
def fill_textbox(
writer: fitz.TextWriter,
rect: rect_like,
text: typing.Union[str, list],
pos: point_like = None,
font: typing.Optional[fitz.Font] = None,
fontsize: float = 11,
lineheight: OptFloat = None,
align: int = 0,
warn: bool = None,
right_to_left: bool = False,
small_caps: bool = False,
) -> tuple:
"""Fill a rectangle with text.
Args:
writer: fitz.TextWriter object (= "self")
rect: rect-like to receive the text.
text: string or list/tuple of strings.
pos: point-like start position of first word.
font: fitz.Font object (default fitz.Font('helv')).
fontsize: the fontsize.
lineheight: overwrite the font property
align: (int) 0 = left, 1 = center, 2 = right, 3 = justify
warn: (bool) text overflow action: none, warn, or exception
right_to_left: (bool) indicate right-to-left language.
"""
rect = fitz.Rect(rect)
if rect.is_empty:
raise ValueError("fill rect must not empty.")
if type(font) is not fitz.Font:
font = fitz.Font("helv")
def textlen(x):
"""Return length of a string."""
return font.text_length(
x, fontsize=fontsize, small_caps=small_caps
)
def char_lengths(x):
"""Return list of single character lengths for a string."""
return font.char_lengths(x, fontsize=fontsize, small_caps=small_caps)
def append_this(pos, text):
ret = writer.append(
pos, text, font=font, fontsize=fontsize, small_caps=small_caps
)
return ret
tolerance = fontsize * 0.2
space_len = textlen(" ")
std_width = rect.width - tolerance
std_start = rect.x0 + tolerance
def norm_words(width, words):
"""Cut any word in pieces no longer than 'width'."""
nwords = []
word_lengths = []
for w in words:
wl_lst = char_lengths(w)
wl = sum(wl_lst)
if wl <= width:
nwords.append(w)
word_lengths.append(wl)
continue
n = len(wl_lst)
while n > 0:
wl = sum(wl_lst[:n])
if wl <= width:
nwords.append(w[:n])
word_lengths.append(wl)
w = w[n:]
wl_lst = wl_lst[n:]
n = len(wl_lst)
else:
n -= 1
return nwords, word_lengths
def output_justify(start, line):
"""Justified output of a line."""
words = [w for w in line.split(" ") if w != ""]
nwords = len(words)
if nwords == 0:
return
if nwords == 1:
append_this(start, words[0])
return
tl = sum([textlen(w) for w in words])
gaps = nwords - 1
gapl = (std_width - tl) / gaps
for w in words:
_, lp = append_this(start, w)
start.x = lp.x + gapl
return
asc = font.ascender
dsc = font.descender
if not lineheight:
if asc - dsc <= 1:
lheight = 1.2
else:
lheight = asc - dsc
else:
lheight = lineheight
LINEHEIGHT = fontsize * lheight
width = std_width
if pos is not None:
pos = fitz.Point(pos)
else:
pos = rect.tl + (tolerance, fontsize * asc)
if pos not in rect:
raise ValueError("Text must start in rectangle.")
if align == fitz.TEXT_ALIGN_CENTER:
factor = 0.5
elif align == fitz.TEXT_ALIGN_RIGHT:
factor = 1.0
else:
factor = 0
if type(text) is str:
textlines = text.splitlines()
else:
textlines = []
for line in text:
textlines.extend(line.splitlines())
max_lines = int((rect.y1 - pos.y) / LINEHEIGHT) + 1
new_lines = []
no_justify = []
for i, line in enumerate(textlines):
if line in ("", " "):
new_lines.append((line, space_len))
width = rect.width - tolerance
no_justify.append((len(new_lines) - 1))
continue
if i == 0:
width = rect.x1 - pos.x
else:
width = rect.width - tolerance
if right_to_left:
line = writer.clean_rtl(line)
tl = textlen(line)
if tl <= width:
new_lines.append((line, tl))
no_justify.append((len(new_lines) - 1))
continue
words = line.split(" ")
words, word_lengths = norm_words(std_width, words)
n = len(words)
while True:
line0 = " ".join(words[:n])
wl = sum(word_lengths[:n]) + space_len * (len(word_lengths[:n]) - 1)
if wl <= width:
new_lines.append((line0, wl))
words = words[n:]
word_lengths = word_lengths[n:]
n = len(words)
line0 = None
else:
n -= 1
if len(words) == 0:
break
nlines = len(new_lines)
if nlines > max_lines:
msg = "Only fitting %i of %i lines." % (max_lines, nlines)
if warn is True:
print("Warning: " + msg)
elif warn is False:
raise ValueError(msg)
start = fitz.Point()
no_justify += [len(new_lines) - 1]
for i in range(max_lines):
try:
line, tl = new_lines.pop(0)
except IndexError:
if g_exceptions_verbose: fitz.exception_info()
break
if right_to_left:
line = "".join(reversed(line))
if i == 0:
start = pos
if align == fitz.TEXT_ALIGN_JUSTIFY and i not in no_justify and tl < std_width:
output_justify(start, line)
start.x = std_start
start.y += LINEHEIGHT
continue
if i > 0 or pos.x == std_start:
start.x += (width - tl) * factor
append_this(start, line)
start.x = std_start
start.y += LINEHEIGHT
return new_lines
def get_oc(doc: fitz.Document, xref: int) -> int:
"""Return optional content object xref for an image or form xobject.
Args:
xref: (int) xref number of an image or form xobject.
"""
if doc.is_closed or doc.is_encrypted:
raise ValueError("document close or encrypted")
t, name = doc.xref_get_key(xref, "Subtype")
if t != "name" or name not in ("/Image", "/Form"):
raise ValueError("bad object type at xref %i" % xref)
t, oc = doc.xref_get_key(xref, "OC")
if t != "xref":
return 0
rc = int(oc.replace("0 R", ""))
return rc
def set_oc(doc: fitz.Document, xref: int, oc: int) -> None:
"""Attach optional content object to image or form xobject.
Args:
xref: (int) xref number of an image or form xobject
oc: (int) xref number of an OCG or OCMD
"""
if doc.is_closed or doc.is_encrypted:
raise ValueError("document close or encrypted")
t, name = doc.xref_get_key(xref, "Subtype")
if t != "name" or name not in ("/Image", "/Form"):
raise ValueError("bad object type at xref %i" % xref)
if oc > 0:
t, name = doc.xref_get_key(oc, "Type")
if t != "name" or name not in ("/OCG", "/OCMD"):
raise ValueError("bad object type at xref %i" % oc)
if oc == 0 and "OC" in doc.xref_get_keys(xref):
doc.xref_set_key(xref, "OC", "null")
return None
doc.xref_set_key(xref, "OC", "%i 0 R" % oc)
return None
def set_ocmd(
doc: fitz.Document,
xref: int = 0,
ocgs: typing.Union[list, None] = None,
policy: OptStr = None,
ve: typing.Union[list, None] = None,
) -> int:
"""Create or update an OCMD object in a PDF document.
Args:
xref: (int) 0 for creating a new object, otherwise update existing one.
ocgs: (list) OCG xref numbers, which shall be subject to 'policy'.
policy: one of 'AllOn', 'AllOff', 'AnyOn', 'AnyOff' (any casing).
ve: (list) visibility expression. Use instead of 'ocgs' with 'policy'.
Returns:
Xref of the created or updated OCMD.
"""
all_ocgs = set(doc.get_ocgs().keys())
def ve_maker(ve):
if type(ve) not in (list, tuple) or len(ve) < 2:
raise ValueError("bad 've' format: %s" % ve)
if ve[0].lower() not in ("and", "or", "not"):
raise ValueError("bad operand: %s" % ve[0])
if ve[0].lower() == "not" and len(ve) != 2:
raise ValueError("bad 've' format: %s" % ve)
item = "[/%s" % ve[0].title()
for x in ve[1:]:
if type(x) is int:
if x not in all_ocgs:
raise ValueError("bad OCG %i" % x)
item += " %i 0 R" % x
else:
item += " %s" % ve_maker(x)
item += "]"
return item
text = "<</Type/OCMD"
if ocgs and type(ocgs) in (list, tuple):
s = set(ocgs).difference(all_ocgs)
if s != set():
msg = "bad OCGs: %s" % s
raise ValueError(msg)
text += "/OCGs[" + " ".join(map(lambda x: "%i 0 R" % x, ocgs)) + "]"
if policy:
policy = str(policy).lower()
pols = {
"anyon": "AnyOn",
"allon": "AllOn",
"anyoff": "AnyOff",
"alloff": "AllOff",
}
if policy not in ("anyon", "allon", "anyoff", "alloff"):
raise ValueError("bad policy: %s" % policy)
text += "/P/%s" % pols[policy]
if ve:
text += "/VE%s" % ve_maker(ve)
text += ">>"
if xref == 0:
xref = doc.get_new_xref()
elif "/Type/OCMD" not in doc.xref_object(xref, compressed=True):
raise ValueError("bad xref or not an OCMD")
doc.update_object(xref, text)
return xref
def get_ocmd(doc: fitz.Document, xref: int) -> dict:
"""Return the definition of an OCMD (optional content membership dictionary).
Recognizes PDF dict keys /OCGs (PDF array of OCGs), /P (policy string) and
/VE (visibility expression, PDF array). Via string manipulation, this
info is converted to a Python dictionary with keys "xref", "ocgs", "policy"
and "ve" - ready to recycle as input for 'set_ocmd()'.
"""
if xref not in range(doc.xref_length()):
raise ValueError("bad xref")
text = doc.xref_object(xref, compressed=True)
if "/Type/OCMD" not in text:
raise ValueError("bad object type")
textlen = len(text)
p0 = text.find("/OCGs[")
p1 = text.find("]", p0)
if p0 < 0 or p1 < 0:
ocgs = None
else:
ocgs = text[p0 + 6 : p1].replace("0 R", " ").split()
ocgs = list(map(int, ocgs))
p0 = text.find("/P/")
if p0 < 0:
policy = None
else:
p1 = text.find("ff", p0)
if p1 < 0:
p1 = text.find("on", p0)
if p1 < 0:
raise ValueError("bad object at xref")
else:
policy = text[p0 + 3 : p1 + 2]
p0 = text.find("/VE[")
if p0 < 0:
ve = None
else:
lp = rp = 0
p1 = p0
while lp < 1 or lp != rp:
p1 += 1
if not p1 < textlen:
raise ValueError("bad object at xref")
if text[p1] == "[":
lp += 1
if text[p1] == "]":
rp += 1
ve = text[p0 + 3 : p1 + 1]
ve = (
ve.replace("/And", '"and",')
.replace("/Not", '"not",')
.replace("/Or", '"or",')
)
ve = ve.replace(" 0 R]", "]").replace(" 0 R", ",").replace("][", "],[")
import json
try:
ve = json.loads(ve)
except Exception:
fitz.exception_info()
print("bad /VE key: ", ve)
raise
return {"xref": xref, "ocgs": ocgs, "policy": policy, "ve": ve}
"""
Handle page labels for PDF documents.
Reading
-------
* compute the label of a page
* find page number(s) having the given label.
Writing
-------
Supports setting (defining) page labels for PDF documents.
A big Thank You goes to WILLIAM CHAPMAN who contributed the idea and
significant parts of the following code during late December 2020
through early January 2021.
"""
def rule_dict(item):
"""Make a Python dict from a PDF page label rule.
Args:
item -- a tuple (pno, rule) with the start page number and the rule
string like <</S/D...>>.
Returns:
A dict like
{'startpage': int, 'prefix': str, 'style': str, 'firstpagenum': int}.
"""
pno, rule = item
rule = rule[2:-2].split("/")[1:]
d = {"startpage": pno, "prefix": "", "firstpagenum": 1}
skip = False
for i, item in enumerate(rule):
if skip:
skip = False
continue
if item == "S":
d["style"] = rule[i + 1]
skip = True
continue
if item.startswith("P"):
x = item[1:].replace("(", "").replace(")", "")
d["prefix"] = x
continue
if item.startswith("St"):
x = int(item[2:])
d["firstpagenum"] = x
return d
def get_label_pno(pgNo, labels):
"""Return the label for this page number.
Args:
pgNo: page number, 0-based.
labels: result of doc._get_page_labels().
Returns:
The label (str) of the page number. Errors return an empty string.
"""
item = [x for x in labels if x[0] <= pgNo][-1]
rule = rule_dict(item)
prefix = rule.get("prefix", "")
style = rule.get("style", "")
pagenumber = pgNo - rule["startpage"] + rule["firstpagenum"]
return construct_label(style, prefix, pagenumber)
def get_label(page):
"""Return the label for this PDF page.
Args:
page: page object.
Returns:
The label (str) of the page. Errors return an empty string.
"""
labels = page.parent._get_page_labels()
if not labels:
return ""
labels.sort()
return get_label_pno(page.number, labels)
def get_page_numbers(doc, label, only_one=False):
"""Return a list of page numbers with the given label.
Args:
doc: PDF document object (resp. 'self').
label: (str) label.
only_one: (bool) stop searching after first hit.
Returns:
List of page numbers having this label.
"""
numbers = []
if not label:
return numbers
labels = doc._get_page_labels()
if labels == []:
return numbers
for i in range(doc.page_count):
plabel = get_label_pno(i, labels)
if plabel == label:
numbers.append(i)
if only_one:
break
return numbers
def construct_label(style, prefix, pno) -> str:
"""Construct a label based on style, prefix and page number."""
n_str = ""
if style == "D":
n_str = str(pno)
elif style == "r":
n_str = integerToRoman(pno).lower()
elif style == "R":
n_str = integerToRoman(pno).upper()
elif style == "a":
n_str = integerToLetter(pno).lower()
elif style == "A":
n_str = integerToLetter(pno).upper()
result = prefix + n_str
return result
def integerToLetter(i) -> str:
"""Returns letter sequence string for integer i."""
import string
ls = string.ascii_uppercase
n, a = 1, i
while pow(26, n) <= a:
a -= int(math.pow(26, n))
n += 1
str_t = ""
for j in reversed(range(n)):
f, g = divmod(a, int(math.pow(26, j)))
str_t += ls[f]
a = g
return str_t
def integerToRoman(num: int) -> str:
"""Return roman numeral for an integer."""
roman = (
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
)
def roman_num(num):
for r, ltr in roman:
x, _ = divmod(num, r)
yield ltr * x
num -= r * x
if num <= 0:
break
return "".join([a for a in roman_num(num)])
def get_page_labels(doc):
"""Return page label definitions in PDF document.
Args:
doc: PDF document (resp. 'self').
Returns:
A list of dictionaries with the following format:
{'startpage': int, 'prefix': str, 'style': str, 'firstpagenum': int}.
"""
return [rule_dict(item) for item in doc._get_page_labels()]
def set_page_labels(doc, labels):
"""Add / replace page label definitions in PDF document.
Args:
doc: PDF document (resp. 'self').
labels: list of label dictionaries like:
{'startpage': int, 'prefix': str, 'style': str, 'firstpagenum': int},
as returned by get_page_labels().
"""
def create_label_str(label):
"""Convert Python label dict to correspnding PDF rule string.
Args:
label: (dict) build rule for the label.
Returns:
PDF label rule string wrapped in "<<", ">>".
"""
s = "%i<<" % label["startpage"]
if label.get("prefix", "") != "":
s += "/P(%s)" % label["prefix"]
if label.get("style", "") != "":
s += "/S/%s" % label["style"]
if label.get("firstpagenum", 1) > 1:
s += "/St %i" % label["firstpagenum"]
s += ">>"
return s
def create_nums(labels):
"""Return concatenated string of all labels rules.
Args:
labels: (list) dictionaries as created by function 'rule_dict'.
Returns:
PDF compatible string for page label definitions, ready to be
enclosed in PDF array 'Nums[...]'.
"""
labels.sort(key=lambda x: x["startpage"])
s = "".join([create_label_str(label) for label in labels])
return s
doc._set_page_labels(create_nums(labels))
def has_links(doc: fitz.Document) -> bool:
"""Check whether there are links on any page."""
if doc.is_closed:
raise ValueError("document closed")
if not doc.is_pdf:
raise ValueError("is no PDF")
for i in range(doc.page_count):
for item in doc.page_annot_xrefs(i):
if item[1] == fitz.PDF_ANNOT_LINK:
return True
return False
def has_annots(doc: fitz.Document) -> bool:
"""Check whether there are annotations on any page."""
if doc.is_closed:
raise ValueError("document closed")
if not doc.is_pdf:
raise ValueError("is no PDF")
for i in range(doc.page_count):
for item in doc.page_annot_xrefs(i):
if not (item[1] == fitz.PDF_ANNOT_LINK or item[1] == fitz.PDF_ANNOT_WIDGET):
return True
return False
def recover_bbox_quad(line_dir: tuple, span: dict, bbox: tuple) -> fitz.Quad:
"""Compute the quad located inside the bbox.
The bbox may be any of the resp. tuples occurring inside the given span.
Args:
line_dir: (tuple) 'line["dir"]' of the owning line or None.
span: (dict) the span. May be from get_texttrace() method.
bbox: (tuple) the bbox of the span or any of its characters.
Returns:
The quad which is wrapped by the bbox.
"""
if line_dir is None:
line_dir = span["dir"]
cos, sin = line_dir
bbox = fitz.Rect(bbox)
if fitz.TOOLS.set_small_glyph_heights():
d = 1
else:
d = span["ascender"] - span["descender"]
height = d * span["size"]
hs = height * sin
hc = height * cos
if hc >= 0 and hs <= 0:
ul = bbox.bl - (0, hc)
ur = bbox.tr + (hs, 0)
ll = bbox.bl - (hs, 0)
lr = bbox.tr + (0, hc)
elif hc <= 0 and hs <= 0:
ul = bbox.br + (hs, 0)
ur = bbox.tl - (0, hc)
ll = bbox.br + (0, hc)
lr = bbox.tl - (hs, 0)
elif hc <= 0 and hs >= 0:
ul = bbox.tr - (0, hc)
ur = bbox.bl + (hs, 0)
ll = bbox.tr - (hs, 0)
lr = bbox.bl + (0, hc)
else:
ul = bbox.tl + (hs, 0)
ur = bbox.br - (0, hc)
ll = bbox.tl + (0, hc)
lr = bbox.br - (hs, 0)
return fitz.Quad(ul, ur, ll, lr)
def recover_quad(line_dir: tuple, span: dict) -> fitz.Quad:
"""Recover the quadrilateral of a text span.
Args:
line_dir: (tuple) 'line["dir"]' of the owning line.
span: the span.
Returns:
The quadrilateral enveloping the span's text.
"""
if type(line_dir) is not tuple or len(line_dir) != 2:
raise ValueError("bad line dir argument")
if type(span) is not dict:
raise ValueError("bad span argument")
return recover_bbox_quad(line_dir, span, span["bbox"])
def recover_line_quad(line: dict, spans: list = None) -> fitz.Quad:
"""Calculate the line quad for 'dict' / 'rawdict' text extractions.
The lower quad points are those of the first, resp. last span quad.
The upper points are determined by the maximum span quad height.
From this, compute a rect with bottom-left in (0, 0), convert this to a
quad and rotate and shift back to cover the text of the spans.
Args:
spans: (list, optional) sub-list of spans to consider.
Returns:
fitz.Quad covering selected spans.
"""
if spans is None:
spans = line["spans"]
if len(spans) == 0:
raise ValueError("bad span list")
line_dir = line["dir"]
cos, sin = line_dir
q0 = recover_quad(line_dir, spans[0])
if len(spans) > 1:
q1 = recover_quad(line_dir, spans[-1])
else:
q1 = q0
line_ll = q0.ll
line_lr = q1.lr
mat0 = fitz.planish_line(line_ll, line_lr)
x_lr = line_lr * mat0
small = fitz.TOOLS.set_small_glyph_heights()
h = max(
[s["size"] * (1 if small else (s["ascender"] - s["descender"])) for s in spans]
)
line_rect = fitz.Rect(0, -h, x_lr.x, 0)
line_quad = line_rect.quad
line_quad *= ~mat0
return line_quad
def recover_span_quad(line_dir: tuple, span: dict, chars: list = None) -> fitz.Quad:
"""Calculate the span quad for 'dict' / 'rawdict' text extractions.
Notes:
There are two execution paths:
1. For the full span quad, the result of 'recover_quad' is returned.
2. For the quad of a sub-list of characters, the char quads are
computed and joined. This is only supported for the "rawdict"
extraction option.
Args:
line_dir: (tuple) 'line["dir"]' of the owning line.
span: (dict) the span.
chars: (list, optional) sub-list of characters to consider.
Returns:
fitz.Quad covering selected characters.
"""
if line_dir is None:
line_dir = span["dir"]
if chars is None:
return recover_quad(line_dir, span)
if "chars" not in span.keys():
raise ValueError("need 'rawdict' option to sub-select chars")
q0 = recover_char_quad(line_dir, span, chars[0])
if len(chars) > 1:
q1 = recover_char_quad(line_dir, span, chars[-1])
else:
q1 = q0
span_ll = q0.ll
span_lr = q1.lr
mat0 = fitz.planish_line(span_ll, span_lr)
x_lr = span_lr * mat0
small = fitz.TOOLS.set_small_glyph_heights()
h = span["size"] * (1 if small else (span["ascender"] - span["descender"]))
span_rect = fitz.Rect(0, -h, x_lr.x, 0)
span_quad = span_rect.quad
span_quad *= ~mat0
return span_quad
def recover_char_quad(line_dir: tuple, span: dict, char: dict) -> fitz.Quad:
"""Recover the quadrilateral of a text character.
This requires the "rawdict" option of text extraction.
Args:
line_dir: (tuple) 'line["dir"]' of the span's line.
span: (dict) the span dict.
char: (dict) the character dict.
Returns:
The quadrilateral enveloping the character.
"""
if line_dir is None:
line_dir = span["dir"]
if type(line_dir) is not tuple or len(line_dir) != 2:
raise ValueError("bad line dir argument")
if type(span) is not dict:
raise ValueError("bad span argument")
if type(char) is dict:
bbox = fitz.Rect(char["bbox"])
elif type(char) is tuple:
bbox = fitz.Rect(char[3])
else:
raise ValueError("bad span argument")
return recover_bbox_quad(line_dir, span, bbox)
def subset_fonts(doc: fitz.Document, verbose: bool = False) -> None:
"""Build font subsets of a PDF. Requires package 'fontTools'.
Eligible fonts are potentially replaced by smaller versions. Page text is
NOT rewritten and thus should retain properties like being hidden or
controlled by optional content.
"""
font_buffers = {}
def get_old_widths(xref):
"""Retrieve old font '/W' and '/DW' values."""
df = doc.xref_get_key(xref, "DescendantFonts")
if df[0] != "array":
return None, None
df_xref = int(df[1][1:-1].replace("0 R", ""))
widths = doc.xref_get_key(df_xref, "W")
if widths[0] != "array":
widths = None
else:
widths = widths[1]
dwidths = doc.xref_get_key(df_xref, "DW")
if dwidths[0] != "int":
dwidths = None
else:
dwidths = dwidths[1]
return widths, dwidths
def set_old_widths(xref, widths, dwidths):
"""Restore the old '/W' and '/DW' in subsetted font.
If either parameter is None or evaluates to False, the corresponding
dictionary key will be set to null.
"""
df = doc.xref_get_key(xref, "DescendantFonts")
if df[0] != "array":
return None
df_xref = int(df[1][1:-1].replace("0 R", ""))
if (type(widths) is not str or not widths) and doc.xref_get_key(df_xref, "W")[
0
] != "null":
doc.xref_set_key(df_xref, "W", "null")
else:
doc.xref_set_key(df_xref, "W", widths)
if (type(dwidths) is not str or not dwidths) and doc.xref_get_key(
df_xref, "DW"
)[0] != "null":
doc.xref_set_key(df_xref, "DW", "null")
else:
doc.xref_set_key(df_xref, "DW", dwidths)
return None
def set_subset_fontname(new_xref):
"""Generate a name prefix to tag a font as subset.
We use a random generator to select 6 upper case ASCII characters.
The prefixed name must be put in the font xref as the "/BaseFont" value
and in the FontDescriptor object as the '/FontName' value.
"""
import random
import string
prefix = "".join(random.choices(tuple(string.ascii_uppercase), k=6)) + "+"
font_str = doc.xref_object(new_xref, compressed=True)
font_str = font_str.replace("/BaseFont/", "/BaseFont/" + prefix)
df = doc.xref_get_key(new_xref, "DescendantFonts")
if df[0] == "array":
df_xref = int(df[1][1:-1].replace("0 R", ""))
fd = doc.xref_get_key(df_xref, "FontDescriptor")
if fd[0] == "xref":
fd_xref = int(fd[1].replace("0 R", ""))
fd_str = doc.xref_object(fd_xref, compressed=True)
fd_str = fd_str.replace("/FontName/", "/FontName/" + prefix)
doc.update_object(fd_xref, fd_str)
doc.update_object(new_xref, font_str)
return None
def build_subset(buffer, unc_set, gid_set):
"""Build font subset using fontTools.
Args:
buffer: (bytes) the font given as a binary buffer.
unc_set: (set) required glyph ids.
Returns:
Either None if subsetting is unsuccessful or the subset font buffer.
"""
try:
import fontTools.subset as fts
except ImportError:
if g_exceptions_verbose: fitz.exception_info()
print("This method requires fontTools to be installed.")
raise
import tempfile
tmp_dir = tempfile.gettempdir()
oldfont_path = f"{tmp_dir}/oldfont.ttf"
newfont_path = f"{tmp_dir}/newfont.ttf"
uncfile_path = f"{tmp_dir}/uncfile.txt"
args = [
oldfont_path,
"--retain-gids",
f"--output-file={newfont_path}",
"--layout-features='*'",
"--passthrough-tables",
"--ignore-missing-glyphs",
"--ignore-missing-unicodes",
"--symbol-cmap",
]
unc_file = open(
f"{tmp_dir}/uncfile.txt", "w"
)
if 0xFFFD in unc_set:
args.append(f"--gids-file={uncfile_path}")
gid_set.add(189)
unc_list = list(gid_set)
for unc in unc_list:
unc_file.write("%i\n" % unc)
else:
args.append(f"--unicodes-file={uncfile_path}")
unc_set.add(255)
unc_list = list(unc_set)
for unc in unc_list:
unc_file.write("%04x\n" % unc)
unc_file.close()
fontfile = open(oldfont_path, "wb")
fontfile.write(buffer)
fontfile.close()
try:
os.remove(newfont_path)
except Exception:
pass
try:
fts.main(args)
font = fitz.Font(fontfile=newfont_path)
new_buffer = font.buffer
if len(font.valid_codepoints()) == 0:
new_buffer = None
except Exception:
fitz.exception_info()
new_buffer = None
try:
os.remove(uncfile_path)
except Exception:
fitz.exception_info()
pass
try:
os.remove(oldfont_path)
except Exception:
fitz.exception_info()
pass
try:
os.remove(newfont_path)
except Exception:
fitz.exception_info()
pass
return new_buffer
def repl_fontnames(doc):
"""Populate 'font_buffers'.
For each font candidate, store its xref and the list of names
by which PDF text may refer to it (there may be multiple).
"""
def norm_name(name):
"""Recreate font name that contains PDF hex codes.
E.g. #20 -> space, chr(32)
"""
while "#" in name:
p = name.find("#")
c = int(name[p + 1 : p + 3], 16)
name = name.replace(name[p : p + 3], chr(c))
return name
def get_fontnames(doc, item):
"""Return a list of fontnames for an item of page.get_fonts().
There may be multiple names e.g. for Type0 fonts.
"""
fontname = item[3]
names = [fontname]
fontname = doc.xref_get_key(item[0], "BaseFont")[1][1:]
fontname = norm_name(fontname)
if fontname not in names:
names.append(fontname)
descendents = doc.xref_get_key(item[0], "DescendantFonts")
if descendents[0] != "array":
return names
descendents = descendents[1][1:-1]
if descendents.endswith(" 0 R"):
xref = int(descendents[:-4])
descendents = doc.xref_object(xref, compressed=True)
p1 = descendents.find("/BaseFont")
if p1 >= 0:
p2 = descendents.find("/", p1 + 1)
p1 = min(descendents.find("/", p2 + 1), descendents.find(">>", p2 + 1))
fontname = descendents[p2 + 1 : p1]
fontname = norm_name(fontname)
if fontname not in names:
names.append(fontname)
return names
for i in range(doc.page_count):
for f in doc.get_page_fonts(i, full=True):
font_xref = f[0]
font_ext = f[1]
basename = f[3]
if font_ext not in (
"otf",
"ttf",
"woff",
"woff2",
):
continue
if len(basename) > 6 and basename[6] == "+":
continue
extr = doc.extract_font(font_xref)
fontbuffer = extr[-1]
names = get_fontnames(doc, f)
name_set, xref_set, subsets = font_buffers.get(
fontbuffer, (set(), set(), (set(), set()))
)
xref_set.add(font_xref)
for name in names:
name_set.add(name)
font = fitz.Font(fontbuffer=fontbuffer)
name_set.add(font.name)
del font
font_buffers[fontbuffer] = (name_set, xref_set, subsets)
return None
def find_buffer_by_name(name):
for buffer in font_buffers.keys():
name_set, _, _ = font_buffers[buffer]
if name in name_set:
return buffer
return None
repl_fontnames(doc)
if not font_buffers:
if verbose:
print(f'No fonts to subset.')
return 0
old_fontsize = 0
new_fontsize = 0
for fontbuffer in font_buffers.keys():
old_fontsize += len(fontbuffer)
for page in doc:
for span in page.get_texttrace():
if type(span) is not dict:
continue
fontname = span["font"][:33]
buffer = find_buffer_by_name(fontname)
if buffer is None:
continue
name_set, xref_set, (set_ucs, set_gid) = font_buffers[buffer]
for c in span["chars"]:
set_ucs.add(c[0])
set_gid.add(c[1])
font_buffers[buffer] = (name_set, xref_set, (set_ucs, set_gid))
for old_buffer in font_buffers.keys():
name_set, xref_set, subsets = font_buffers[old_buffer]
new_buffer = build_subset(old_buffer, subsets[0], subsets[1])
fontname = list(name_set)[0]
if new_buffer is None or len(new_buffer) >= len(old_buffer):
if verbose:
print(f'Cannot subset {fontname!r}.')
continue
if verbose:
print('Built subset of font {fontname!r}.')
val = doc._insert_font(fontbuffer=new_buffer)
new_xref = val[0]
set_subset_fontname(new_xref)
font_str = doc.xref_object(
new_xref,
compressed=True,
)
for font_xref in xref_set:
width_table, def_width = get_old_widths(font_xref)
doc.update_object(font_xref, font_str)
if width_table or def_width:
set_old_widths(font_xref, width_table, def_width)
new_fontsize += len(new_buffer)
return old_fontsize - new_fontsize
def xref_copy(doc: fitz.Document, source: int, target: int, *, keep: list = None) -> None:
"""Copy a PDF dictionary object to another one given their xref numbers.
Args:
doc: PDF document object
source: source xref number
target: target xref number, the xref must already exist
keep: an optional list of 1st level keys in target that should not be
removed before copying.
Notes:
This works similar to the copy() method of dictionaries in Python. The
source may be a stream object.
"""
if doc.xref_is_stream(source):
stream = doc.xref_stream_raw(source)
doc.update_stream(
target,
stream,
compress=False,
new=True,
)
if keep is None:
keep = []
for key in doc.xref_get_keys(target):
if key in keep:
continue
doc.xref_set_key(target, key, "null")
for key in doc.xref_get_keys(source):
item = doc.xref_get_key(source, key)
doc.xref_set_key(target, key, item[1])
return None