@@ -602,72 +602,132 @@ form of word-wrapping, and users of the ``wand`` library would be responsible
602602for implementing this behavior unique to their business requirements.
603603
604604ImageMagick's ``caption: `` coder does offer a word-wrapping solution with
605- :meth: `Image.caption() <wand.image.BaseImage.caption> ` method, but Python's :mod: ` textwrap ` is
605+ :meth: `Image.caption() <wand.image.BaseImage.caption> ` method, but this version is
606606a little more sophisticated.
607607
608608.. code ::
609609
610- from textwrap import wrap
611- from wand.color import Color
612610 from wand.drawing import Drawing
613611 from wand.image import Image
612+ import re
614613
615614
616- def draw_roi(contxt , roi_width, roi_height):
615+ def draw_roi(ctx , roi_width, roi_height):
617616 """Let's draw a blue box so we can identify what
618617 our region of interest is."""
619618 ctx.push()
620- ctx.stroke_color = Color( 'BLUE')
621- ctx.fill_color = Color( 'TRANSPARENT')
619+ ctx.stroke_color = 'BLUE'
620+ ctx.fill_color = 'TRANSPARENT'
622621 ctx.rectangle(left=75, top=255, width=roi_width, height=roi_height)
623622 ctx.pop()
624623
625624
626625 def word_wrap(image, ctx, text, roi_width, roi_height):
627- """Break long text to multiple lines, and reduce point size
626+ """Break long text to multiple lines, and/or reduce point size
628627 until all text fits within a bounding box."""
629- mutable_message = text
630- iteration_attempts = 100
628+
629+ def substring_font_metrics_width(text, length):
630+ """ Get width of rendered substring. """
631+ metrics = ctx.get_font_metrics(image, text[0:length], True)
632+ return metrics.text_width
631633
632634 def eval_metrics(txt):
633635 """Quick helper function to calculate width/height of text."""
634636 metrics = ctx.get_font_metrics(image, txt, True)
635637 return (metrics.text_width, metrics.text_height)
636638
637- while ctx.font_size > 0 and iteration_attempts:
639+ # Start with the original text.
640+ wrapped_text = text
641+
642+ # If this message can't be wrapped, just scale it.
643+ loop_continues = True
644+ message_width, message_height = eval_metrics(wrapped_text)
645+ if message_width > roi_width and ' ' not in text:
646+ ctx.font_size *= roi_width / message_width # Scale pointsize
647+ loop_continues = False
648+
649+ # Loop until a successful word-wrap is calculated.
650+ iteration_attempts = 100
651+ while loop_continues and ctx.font_size > 0 and iteration_attempts:
638652 iteration_attempts -= 1
639- width, height = eval_metrics(mutable_message)
640- if height > roi_height:
641- ctx.font_size -= 0.75 # Reduce pointsize
642- mutable_message = text # Restore original text
643- elif width > roi_width:
644- columns = len(mutable_message)
645- while columns > 0:
646- columns -= 1
647- mutable_message = '\n'.join(wrap(mutable_message, columns))
648- wrapped_width, _ = eval_metrics(mutable_message)
649- if wrapped_width <= roi_width:
653+
654+ # Prepare to break this string into lines.
655+ text_lines = []
656+ while len(wrapped_text) > 0:
657+ # If the rest fits, we're done.
658+ value_mid = substring_font_metrics_width(wrapped_text,
659+ len(wrapped_text))
660+ if value_mid <= roi_width:
661+ text_lines.append(wrapped_text)
662+ wrapped_text = '\n'.join(text_lines)
663+ message_width, message_height = eval_metrics(wrapped_text)
664+ if message_height > roi_height:
665+ ctx.font_size *= 0.9 # Reduce pointsize
666+ wrapped_text = text # Restore original text
667+ else:
668+ loop_continues = False
669+ break
670+
671+ # Find where to break this string so that it fits inside the width.
672+ index_low = 0
673+ index_high = len(wrapped_text) - 1
674+ while index_low <= index_high:
675+ index_mid = (index_low + index_high) // 2
676+ value_mid = substring_font_metrics_width(wrapped_text,
677+ index_mid)
678+ if value_mid == roi_width:
650679 break
651- if columns < 1:
652- ctx.font_size -= 0.75 # Reduce pointsize
653- mutable_message = text # Restore original text
654- else:
655- break
680+ elif value_mid < roi_width:
681+ index_low = index_mid + 1
682+ index_mid = index_low
683+ else:
684+ index_high = index_mid - 1
685+ index_mid = index_low
686+
687+ # Find the last occurrence of whitespace.
688+ whitespace_matches = list(
689+ re.finditer(r'\s+', wrapped_text[0:index_mid])
690+ )
691+ if whitespace_matches:
692+ index_mid = whitespace_matches[-1].start()
693+ else:
694+ index_mid = -1
695+ if index_mid <= 0:
696+ ctx.font_size *= 0.9 # Reduce pointsize
697+ wrapped_text = text # Restore original text
698+ break
699+ else:
700+
701+ # Break the line here.
702+ text_lines.append(wrapped_text[0:index_mid])
703+
704+ # Prepare to break the rest.
705+ wrapped_text = wrapped_text[index_mid:].lstrip()
706+
707+ # If the incompletely wrapped text is already too high,
708+ # reduce the font size and try again.
709+ message_so_far = '\n'.join(text_lines + [wrapped_text])
710+ message_width, message_height = eval_metrics(message_so_far)
711+ if message_height > roi_height:
712+ ctx.font_size *= 0.9 # Reduce pointsize
713+ wrapped_text = text # Restore original text
714+ break
715+
656716 if iteration_attempts < 1:
657717 raise RuntimeError("Unable to calculate word_wrap for " + text)
658- return mutable_message
718+ return wrapped_text
659719
660720
661- message = """This is some really long sentence with the
662- word "Mississippi" in it."""
721+ message = ( """This is some really long sentence with"""
722+ """ the word "Mississippi" in it.""")
663723
664724 ROI_SIDE = 175
665725
666726 with Image(filename='logo:') as img:
667727 with Drawing() as ctx:
668728 draw_roi(ctx, ROI_SIDE, ROI_SIDE)
669729 # Set the font style
670- ctx.fill_color = Color( 'RED')
730+ ctx.fill_color = 'RED'
671731 ctx.font_family = 'Times New Roman'
672732 ctx.font_size = 32
673733 mutable_message = word_wrap(img,
0 commit comments