diff --git a/docs/page.rst b/docs/page.rst index 54b4b7e23..12704deba 100644 --- a/docs/page.rst +++ b/docs/page.rst @@ -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| diff --git a/pipcl.py b/pipcl.py index 4fee4fb65..89abc6425 100644 --- a/pipcl.py +++ b/pipcl.py @@ -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 diff --git a/src/__init__.py b/src/__init__.py index 16905771b..16182e130 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -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: @@ -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 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") @@ -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) @@ -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): @@ -16045,7 +16068,9 @@ 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`: @@ -16053,7 +16078,7 @@ class FitResult: `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 @@ -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`. @@ -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 @@ -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( @@ -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. @@ -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): ''' diff --git a/tests/gentle_compare.py b/tests/gentle_compare.py index b83fdd5f6..aa155bb7c 100644 --- a/tests/gentle_compare.py +++ b/tests/gentle_compare.py @@ -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)): @@ -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 diff --git a/tests/resources/test_4613.png b/tests/resources/test_4613.png new file mode 100644 index 000000000..e02b7328d Binary files /dev/null and b/tests/resources/test_4613.png differ diff --git a/tests/test_textbox.py b/tests/test_textbox.py index d8003624d..21b253cf0 100644 --- a/tests/test_textbox.py +++ b/tests/test_textbox.py @@ -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. @@ -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 @@ -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