Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions docs/page.rst
Original file line number Diff line number Diff line change
Expand Up @@ -847,14 +847,24 @@ In a nutshell, this is what you can do with PyMuPDF:

:returns: A tuple of floats `(spare_height, scale)`.

- `spare_height`: -1 if content did not fit, else >= 0. It is the height of the unused (still available) rectangle stripe. Positive only if scale = 1 (no down-scaling happened).
- `scale`: down-scaling factor, 0 < scale <= 1.
- spare_height: The (positive) height of the remaining space in `rect` below the
text, or -1 if we failed to fit.
- scale: The scaling required; `0 < scale <= 1`. Will be `scale_low`
if we failed to fit.

Please refer to examples in this section of the recipes: :ref:`RecipesText_I_c`.
Please refer to examples in this section of the recipes: :ref:`RecipesText_I_c`.

|history_begin|

* New in v1.23.8; rebased-only.
* New in v1.26.5:

* do additional scaling to fit long words.
*
If we succeeded and scaled down, the returned `spare_height` is now
generally positive instead of being fixed to zero, because the final
rect's height is usually not an exact multiple of the font line
height.
* New in v1.23.8: rebased-only.
* New in v1.23.9: `opacity` parameter.

|history_end|
Expand Down
2 changes: 1 addition & 1 deletion pipcl.py
Original file line number Diff line number Diff line change
Expand Up @@ -1990,7 +1990,7 @@ def git_info( directory):
)
if not e:
branch = out.strip()
log(f'git_info(): directory={directory!r} returning branch={branch!r} sha={sha!r} comment={comment!r}')
log1(f'git_info(): directory={directory!r} returning branch={branch!r} sha={sha!r} comment={comment!r}')
return sha, comment, diff, branch


Expand Down
88 changes: 59 additions & 29 deletions src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12266,7 +12266,9 @@ def insert_htmlbox(
oc=0,
opacity=1,
overlay=True,
) -> float:
_scale_word_width=True,
_verbose=False,
) -> tuple:
"""Insert text with optional HTML tags and stylings into a rectangle.

Args:
Expand All @@ -12282,14 +12284,17 @@ def insert_htmlbox(
oc: (int) the xref of an OCG / OCMD (Optional Content).
opacity: (float) set opacity of inserted content.
overlay: (bool) put text on top of page content.
_scale_word_width: internal, for testing only.
_verbose: internal, for testing only.
Returns:
A tuple of floats (spare_height, scale).
spare_height: -1 if content did not fit, else >= 0. It is the height of the
unused (still available) rectangle stripe. Positive only if
scale_min = 1 (no down scaling).
scale: downscaling factor, 0 < scale <= 1. Set to 0 if spare_height = -1 (no fit).
spare_height:
The height of the remaining space in <rect> below the
text, or -1 if we failed to fit.
scale:
The scaling required; `0 < scale <= 1`.
Will be less than `scale_low` if we failed to fit.
"""

# normalize rotation angle
if not rotate % 90 == 0:
raise ValueError("bad rotation angle")
Expand Down Expand Up @@ -12320,25 +12325,40 @@ def insert_htmlbox(
story = text
else:
raise ValueError("'text' must be a string or a Story")

# ----------------------------------------------------------------
# Find a scaling factor that lets our story fit in
# Find a scaling factor that lets our story fit in. Instead of scaling
# the text smaller, we instead look at how much bigger the rect needs
# to be to fit the text, then reverse the scaling to get how much we
# need to scale down the text.
# ----------------------------------------------------------------
scale_max = None if scale_low == 0 else 1 / scale_low

fit = story.fit_scale(temp_rect, scale_min=1, scale_max=scale_max)
rect_scale_max = None if scale_low == 0 else 1 / scale_low

fit = story.fit_scale(
temp_rect,
scale_min=1,
scale_max=rect_scale_max,
flags=mupdf.FZ_PLACE_STORY_FLAG_NO_OVERFLOW if _scale_word_width else 0,
verbose=_verbose,
)

if not fit.big_enough: # there was no fit
return (-1, scale_low)

filled = fit.filled
scale = 1 / fit.parameter # shrink factor

spare_height = fit.rect.y1 - filled[3] # unused room at rectangle bottom
# Note: due to MuPDF's logic this may be negative even for successful fits.
if scale != 1 or spare_height < 0: # if scaling occurred, set spare_height to 0
spare_height = 0
scale = 1 / fit.parameter
return (-1, scale)

# fit.filled is a tuple; we convert it in place to a Rect for
# convenience. (fit.rect is already a Rect.)
fit.filled = Rect(fit.filled)
assert (fit.rect.x0, fit.rect.y0) == (0, 0)
assert (fit.filled.x0, fit.filled.y0) == (0, 0)

scale = 1 / fit.parameter
assert scale >= scale_low, f'{scale_low=} {scale=}'

spare_height = max((fit.rect.y1 - fit.filled.y1) * scale, 0)

def rect_function(*args):
return fit.rect, fit.rect, Identity
return fit.rect, fit.rect, None

# draw story on temp PDF page
doc = story.write_with_links(rect_function)
Expand Down Expand Up @@ -15925,10 +15945,13 @@ class Position2:
function( position2)
mupdf.fz_story_positions( self.this, function2)

def place( self, where):
def place( self, where, flags=0):
'''
Wrapper for fz_place_story_flags().
'''
where = JM_rect_from_py( where)
filled = mupdf.FzRect()
more = mupdf.fz_place_story( self.this, where, filled)
more = mupdf.fz_place_story_flags( self.this, where, filled, flags)
return more, JM_py_from_rect( filled)

def reset( self):
Expand Down Expand Up @@ -16045,15 +16068,17 @@ class FitResult:
`big_enough`:
`True` if the fit succeeded.
`filled`:
From the last call to `Story.place()`.
Tuple (x0, y0, x1, y1) from the last call to `Story.place()`. This
will be wider than .rect if any single word (which we never split)
was too wide for .rect.
`more`:
`False` if the fit succeeded.
`numcalls`:
Number of calls made to `self.place()`.
`parameter`:
The successful parameter value, or the largest failing value.
`rect`:
The rect created from `parameter`.
The pumupdf.Rect created from `parameter`.
'''
def __init__(self, big_enough=None, filled=None, more=None, numcalls=None, parameter=None, rect=None):
self.big_enough = big_enough
Expand All @@ -16073,7 +16098,7 @@ def __repr__(self):
f' rect={self.rect}'
)

def fit(self, fn, pmin=None, pmax=None, delta=0.001, verbose=False):
def fit(self, fn, pmin=None, pmax=None, delta=0.001, verbose=False, flags=0):
'''
Finds optimal rect that contains the story `self`.

Expand All @@ -16100,6 +16125,9 @@ def fit(self, fn, pmin=None, pmax=None, delta=0.001, verbose=False):
Maximum error in returned `parameter`.
:arg verbose:
If true we output diagnostics.
:arg flags:
Passed to mupdf.fz_place_story_flags(). e.g.
zero or `mupdf.FZ_PLACE_STORY_FLAG_NO_OVERFLOW`.
'''
def log(text):
assert verbose
Expand Down Expand Up @@ -16155,7 +16183,7 @@ def update(parameter):
if verbose:
log(f'update(): not calling self.place() because rect is empty.')
else:
more, filled = self.place(rect)
more, filled = self.place(rect, flags)
state.numcalls += 1
big_enough = not more
result = Story.FitResult(
Expand Down Expand Up @@ -16224,12 +16252,12 @@ def opposite(p, direction):
parameter = (state.pmin + state.pmax) / 2
update(parameter)

def fit_scale(self, rect, scale_min=0, scale_max=None, delta=0.001, verbose=False):
def fit_scale(self, rect, scale_min=0, scale_max=None, delta=0.001, verbose=False, flags=0):
'''
Finds smallest value `scale` in range `scale_min..scale_max` where
`scale * rect` is large enough to contain the story `self`.

Returns a `Story.FitResult` instance.
Returns a `Story.FitResult` instance with `.parameter` set to `scale`.

:arg width:
width of rect.
Expand All @@ -16244,13 +16272,15 @@ def fit_scale(self, rect, scale_min=0, scale_max=None, delta=0.001, verbose=Fals
Maximum error in returned scale.
:arg verbose:
If true we output diagnostics.
:arg flags:
Passed to Story.place().
'''
x0, y0, x1, y1 = rect
width = x1 - x0
height = y1 - y0
def fn(scale):
return Rect(x0, y0, x0 + scale*width, y0 + scale*height)
return self.fit(fn, scale_min, scale_max, delta, verbose)
return self.fit(fn, scale_min, scale_max, delta, verbose, flags)

def fit_height(self, width, height_min=0, height_max=None, origin=(0, 0), delta=0.001, verbose=False):
'''
Expand Down
4 changes: 1 addition & 3 deletions tests/gentle_compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ def rms(a, b, verbose=None, out_prefix=''):
'''
Returns RMS diff of raw bytes of two sequences.
'''
if verbose is True:
verbose = 100000
assert len(a) == len(b)
e = 0
for i, (aa, bb) in enumerate(zip(a, b)):
Expand Down Expand Up @@ -62,7 +60,7 @@ def pixmaps_rms(a, b, out_prefix=''):
a_mv = a.samples_mv
b_mv = b.samples_mv
assert len(a_mv) == len(b_mv)
ret = rms(a_mv, b_mv, verbose=True, out_prefix=out_prefix)
ret = rms(a_mv, b_mv, out_prefix=out_prefix)
print(f'{out_prefix}pixmaps_rms(): {ret=}.')
return ret

Expand Down
Binary file added tests/resources/test_4613.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
83 changes: 82 additions & 1 deletion tests/test_textbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
"""
import pymupdf

import gentle_compare

import os
import textwrap

# codespell:ignore-begin
text = """Der Kleine Schwertwal (Pseudorca crassidens), auch bekannt als Unechter oder Schwarzer Schwertwal, ist eine Art der Delfine (Delphinidae) und der einzige rezente Vertreter der Gattung Pseudorca.

Expand Down Expand Up @@ -182,7 +187,9 @@ def test_htmlbox1():
assert spare_height < 0
assert scale == 1
spare_height, scale = page.insert_htmlbox(rect, text, rotate=rot, scale_low=0)
assert spare_height == 0
page.draw_rect(rect, (1, 0, 0))
doc.save(os.path.normpath(f'{__file__}/../../tests/test_htmlbox1.pdf'))
assert abs(spare_height - 3.8507) < 0.001
assert 0 < scale < 1
page = doc.reload_page(page)
link = page.get_links()[0] # extracts the links on the page
Expand Down Expand Up @@ -286,3 +293,77 @@ def test_4400():
text = '111111111'
print(f'Calling writer.fill_textbox().', flush=1)
writer.fill_textbox(rect=pymupdf.Rect(0, 0, 100, 20), pos=(80, 0), text=text, fontsize=8)


def test_4613():
print()
text = 3 * 'abcdefghijklmnopqrstuvwxyz\nABCDEFGHIJKLMNOPQRSTUVWXYZ\n'
story = pymupdf.Story(text)
rect = pymupdf.Rect(10, 10, 100, 100)

# Test default operation where we get additional scaling down because of
# the long words in our text.
print(f'test_4613(): ### Testing default operation.')
with pymupdf.open() as doc:
page = doc.new_page()
spare_height, scale = page.insert_htmlbox(rect, story)
print(f'test_4613(): {spare_height=} {scale=}')
# The additional down-scaling from the long word widths results in
# spare vertical space.
page.draw_rect(rect, (1, 0, 0))
path = os.path.normpath(f'{__file__}/../../tests/test_4613.pdf')
doc.save(path)

path_pixmap = os.path.normpath(f'{__file__}/../../tests/test_4613.png')
path_pixmap_expected = os.path.normpath(f'{__file__}/../../tests/resources/test_4613.png')
pixmap = page.get_pixmap(dpi=300)
pixmap.save(path_pixmap)

pixmap_diff = gentle_compare.pixmaps_diff(path_pixmap_expected, pixmap)
pixmap_diff.save(os.path.normpath(f'{__file__}/../../tests/test_4613-diff.png'))

rms = gentle_compare.pixmaps_rms(pixmap, path_pixmap_expected)
print(f'{rms=}')
assert rms == 0, f'{rms=}'

assert abs(spare_height - 45.7536) < 0.1
assert abs(scale - 0.4009) < 0.01

new_text = page.get_text('text', clip=rect)
print(f'test_4613(): new_text:')
print(textwrap.indent(new_text, ' '))
assert new_text == text

# Check with _scale_word_width=False - ignore too-wide words.
print(f'test_4613(): ### Testing with _scale_word_width=False.')
with pymupdf.open() as doc:
page = doc.new_page()
spare_height, scale = page.insert_htmlbox(rect, story, _scale_word_width=False)
print(f'test_4613(): _scale_word_width=False: {spare_height=} {scale=}')
# With _scale_word_width=False we allow long words to extend beyond the
# rect, so we should have spare_height == 0 and only a small amount of
# down-scaling.
assert spare_height == 0
assert abs(scale - 0.914) < 0.01
new_text = page.get_text('text', clip=rect)
print(f'test_4613(): new_text:')
print(textwrap.indent(new_text, ' '))
assert new_text == textwrap.dedent('''
abcdefghijklmno
ABCDEFGHIJKLM
abcdefghijklmno
ABCDEFGHIJKLM
abcdefghijklmno
ABCDEFGHIJKLM
''')[1:]


# Check that we get no fit if scale_low is not low enough.
print(f'test_4613(): ### Testing with scale_low too high to allow a fit.')
with pymupdf.open() as doc:
page = doc.new_page()
scale_low=0.6
spare_height, scale = page.insert_htmlbox(rect, story, scale_low=scale_low)
print(f'test_4613(): {scale_low=}: {spare_height=} {scale=}')
assert spare_height == -1
assert scale == scale_low