paragraph layout fixes; version --> 3.6.4
authorrobin
Mon, 13 Dec 2021 11:47:45 +0000
changeset 4689 4d35ae5cda38
parent 4688 7bf772761aa1
child 4690 820380acd8b4
paragraph layout fixes; version --> 3.6.4
CHANGES.md
src/reportlab/__init__.py
src/reportlab/platypus/paragraph.py
tests/test_platypus_breaking.py
--- a/CHANGES.md	Mon Dec 13 11:47:03 2021 +0000
+++ b/CHANGES.md	Mon Dec 13 11:47:45 2021 +0000
@@ -11,6 +11,12 @@
 The contributors lists are in no order and apologies to those accidentally not
 mentioned. If we missed you, please let us know!
 
+CHANGES  3.6.4	  7/12/2021
+---------------------------
+	* try to improve multi-frag paragraph justification
+	* fix justification condition
+	* allow validator OneOf to take re.Pattern
+
 CHANGES  3.6.3	  4/11/2021
 ---------------------------
 	* modernisation of para.py contribution from <Andrews Searle at BMC dot com>
--- a/src/reportlab/__init__.py	Mon Dec 13 11:47:03 2021 +0000
+++ b/src/reportlab/__init__.py	Mon Dec 13 11:47:45 2021 +0000
@@ -1,9 +1,9 @@
 #Copyright ReportLab Europe Ltd. 2000-2021
 #see license.txt for license details
 __doc__="""The Reportlab PDF generation library."""
-Version = "3.6.3"
+Version = "3.6.4"
 __version__=Version
-__date__='20211118'
+__date__='20211213'
 
 import sys, os
 
--- a/src/reportlab/platypus/paragraph.py	Mon Dec 13 11:47:03 2021 +0000
+++ b/src/reportlab/platypus/paragraph.py	Mon Dec 13 11:47:45 2021 +0000
@@ -201,7 +201,7 @@
 def _justifyDrawParaLine( tx, offset, extraspace, words, last=0):
     setXPos(tx,offset)
     text  = ' '.join(words)
-    simple = last or (-1e-8<extraspace<=1e-8) or getattr(tx,'preformatted',False)
+    simple =  getattr(tx,'preformatted',False) or (-1e-8<extraspace<=1e-8) or (last and extraspace>-1e-8)
     if not simple:
         nSpaces = len(words)+_nbspCount(text)-1
         simple = nSpaces<=0
@@ -503,7 +503,7 @@
     tx._x_offset = offset
     setXPos(tx,offset)
     extraSpace = line.extraSpace
-    simple = last or abs(extraSpace)<=1e-8 or line.lineBreak
+    simple = line.lineBreak or (-1e-8<extraSpace<=1e-8) or (last and extraSpace>-1e-8)
     if not simple:
         nSpaces = line.wordCount+sum([_nbspCount(w.text) for w in line.words if not hasattr(w,'cbDefn')])-1
         simple = nSpaces<=0
@@ -2084,7 +2084,6 @@
                     return f.clone(kind=0, lines=[],ascent=ascent,descent=descent,fontSize=fontSize)
             spaceWidth = stringWidth(' ', fontName, fontSize, self.encoding)
             dSpaceShrink = spaceShrinkage*spaceWidth
-            spaceShrink = 0
             cLine = []
             currentWidth = -spaceWidth   # hack to get around extra space for word 1
             hyw = stringWidth('-', fontName, fontSize, self.encoding)
@@ -2098,12 +2097,14 @@
                 #this underscores my feeling that Unicode throughout would be easier!
                 wordWidth = stringWidth(word, fontName, fontSize, self.encoding)
                 newWidth = currentWidth + spaceWidth + wordWidth
-                if newWidth>maxWidth+spaceShrink and not (isinstance(word,_SplitWordH) or forcedSplit):
+                limWidth = maxWidth + dSpaceShrink*len(cLine)
+                #print(f's: {currentWidth=} spaceShrink={limWidth-maxWidth} {newWidth=} {limWidth=} {newWidth>limWidth} cond={newWidth>limWidth and not (isinstance(word,_SplitWordH) or forcedSplit)} {word=}')
+                if newWidth>limWidth and not (isinstance(word,_SplitWordH) or forcedSplit):
                     if isinstance(word,_SHYStr):
                         hsw = word.__shysplit__(
                                 fontName, fontSize,
                                 currentWidth + spaceWidth + hyw - 1e-8,
-                                maxWidth+spaceShrink,
+                                limWidth,
                                 encoding = self.encoding,
                                 )
                         if hsw:
@@ -2128,7 +2129,7 @@
                     elif attemptHyphenation:
                         hyOk = not getattr(f,'nobr',False)
                         hsw = _hyphenateWord(hyphenator if hyOk else None,
-                                fontName, fontSize, word, wordWidth, newWidth, maxWidth+spaceShrink,
+                                fontName, fontSize, word, wordWidth, newWidth, limWidth,
                                     uriWasteReduce if hyOk else False,
                                     embeddedHyphenation and hyOk, hymwl)
                         if hsw:
@@ -2154,27 +2155,25 @@
                             self._splitLongWordCount += 1
                             forcedSplit = 1
                             continue
-                if newWidth <= (maxWidth+spaceShrink) or not len(cLine) or forcedSplit:
+                if newWidth<=limWidth or not len(cLine) or forcedSplit:
                     # fit one more on this line
                     if word: cLine.append(word)
+                    #print(f's: |line|={len(cLine)} {newWidth=} spaceShrink={limWidth-maxWidth}')
                     if forcedSplit:
                         forcedSplit = 0
                         if newWidth > self._width_max: self._width_max = newWidth
                         lines.append((maxWidth - newWidth, cLine))
                         cLine = []
                         currentWidth = -spaceWidth
-                        spaceShrink = 0
                         lineno += 1
                         maxWidth = maxWidths[min(maxlineno,lineno)]
                     else:
                         currentWidth = newWidth
-                        spaceShrink += dSpaceShrink
                 else:
                     if currentWidth > self._width_max: self._width_max = currentWidth
                     #end of line
                     lines.append((maxWidth - currentWidth, cLine))
                     cLine = [word]
-                    spaceShrink = 0
                     currentWidth = wordWidth
                     lineno += 1
                     maxWidth = maxWidths[min(maxlineno,lineno)]
@@ -2204,7 +2203,7 @@
                 fontSize = f.fontSize
 
                 if not words:
-                    n = dSpaceShrink = spaceShrink = spaceWidth = currentWidth = 0
+                    n = spaceWidth = currentWidth = 0
                     maxSize = fontSize
                     maxAscent, minDescent = getAscentDescent(fontName,fontSize)
 
@@ -2218,9 +2217,20 @@
                 #test to see if this frag is a line break. If it is we will only act on it
                 #if the current width is non-negative or the previous thing was a deliberate lineBreak
                 lineBreak = f._fkind==_FK_BREAK
-                if not lineBreak and newWidth>(maxWidth+spaceShrink) and not isinstance(w,_SplitFragH) and not hasattr(f,'cbDefn'):
+                limWidth = maxWidth
+                if spaceShrinkage:
+                    spaceShrink = spaceWidth
+                    for wi in words:
+                        if wi._fkind==_FK_TEXT:
+                            ns = wi.text.count(' ')
+                            if ns:
+                                spaceShrink += ns*stringWidth(' ',wi.fontName,wi.fontSize)
+                    spaceShrink *= spaceShrinkage
+                    limWidth += spaceShrink
+                #print(f'c: {currentWidth=} {spaceShrink=} {newWidth=} {limWidth=} {newWidth>(maxWidth+spaceShrink)} cond={not lineBreak and newWidth>limWidth and not isinstance(w,_SplitFragH) and not hasattr(f,"cbDefn")} word={w[1][1]}')
+                if not lineBreak and newWidth>limWidth and not isinstance(w,_SplitFragH) and not hasattr(f,'cbDefn'):
                     if isinstance(w,_SHYWord):
-                        hsw = w.shyphenate(newWidth, maxWidth+spaceShrink)
+                        hsw = w.shyphenate(newWidth, limWidth)
                         if hsw:
                             _words[0:0] = hsw
                             _words.insert(1,_InjectedFrag([0,(f.clone(_fkind=_FK_BREAK,text=''),'')]))
@@ -2239,7 +2249,7 @@
                     elif attemptHyphenation:
                         hyOk = not getattr(f,'nobr',False)
                         hsw = _hyphenateFragWord(hyphenator if hyOk else None,
-                                    w,newWidth,maxWidth+spaceShrink,
+                                    w,newWidth,limWidth,
                                     uriWasteReduce if hyOk else False,
                                     embeddedHyphenation and hyOk, hymwl)
                         if hsw:
@@ -2267,8 +2277,9 @@
                             FW.pop(-1)  #remove this as we are doing this one again
                             self._splitLongWordCount += 1
                             continue
-                endLine = (newWidth>(maxWidth+spaceShrink) and n>0) or lineBreak
+                endLine = (newWidth>limWidth and n>0) or lineBreak
                 if not endLine:
+                    #print(f'c: |line|={len(words)} {newWidth=} spaceShrink={limWidth-maxWidth}')
                     if lineBreak: continue      #throw it away
                     nText = w[1][1]
                     if nText: n += 1
@@ -2296,7 +2307,6 @@
                                 if wi._fkind==_FK_TEXT:
                                     if not wi.text.endswith(' '):
                                         wi.text += ' '
-                                        spaceShrink += dSpaceShrink
                                     break
                         g = f.clone()
                         words.append(g)
@@ -2304,14 +2314,12 @@
                     elif spaceWidth:
                         if not g.text.endswith(' '):
                             g.text += ' ' + nText
-                            spaceShrink += dSpaceShrink
                         else:
                             g.text += nText
                     else:
                         g.text += nText
 
                     spaceWidth = stringWidth(' ',fontName,fontSize) if isinstance(w,_HSFrag) else 0 #of the space following this word
-                    dSpaceShrink = spaceWidth*spaceShrinkage
 
                     ni = 0
                     for i in w[2:]:
@@ -2333,18 +2341,21 @@
                     if not nText and ni:
                         #one bit at least of the word was real
                         n+=1
-
+                    #print(f'{n=} words={[_.text for _ in words]!r}')
                     currentWidth = newWidth
                 else:  #either it won't fit, or it's a lineBreak tag
                     if lineBreak:
                         g = f.clone()
                         #del g.lineBreak
                         words.append(g)
+                        llb = njlbv and not isinstance(w,_InjectedFrag)
+                    else:
+                        llb = False
 
                     if currentWidth>self._width_max: self._width_max = currentWidth
                     #end of line
                     lines.append(FragLine(extraSpace=maxWidth-currentWidth, wordCount=n,
-                                        lineBreak=lineBreak and njlbv, words=words, fontSize=maxSize, ascent=maxAscent, descent=minDescent, maxWidth=maxWidth,
+                                        lineBreak=llb, words=words, fontSize=maxSize, ascent=maxAscent, descent=minDescent, maxWidth=maxWidth,
                                         sFW=sFW))
                     sFW = len(FW)-1
 
@@ -2360,7 +2371,6 @@
                     dSpaceShrink = spaceWidth*spaceShrinkage
                     currentWidth = wordWidth
                     n = 1
-                    spaceShrink = 0
                     g = f.clone()
                     maxSize = g.fontSize
                     if calcBounds:
--- a/tests/test_platypus_breaking.py	Mon Dec 13 11:47:03 2021 +0000
+++ b/tests/test_platypus_breaking.py	Mon Dec 13 11:47:45 2021 +0000
@@ -14,7 +14,7 @@
 from reportlab.lib.units import cm, mm
 from reportlab.lib.enums import TA_LEFT, TA_RIGHT, TA_CENTER, TA_JUSTIFY
 from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
-from reportlab.platypus.paragraph import Paragraph
+from reportlab.platypus import ShowBoundaryValue
 from reportlab.platypus.frames import Frame
 from reportlab.lib.randomtext import randomText, PYTHON
 from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate, Indenter, SimpleDocTemplate, LayoutError
@@ -169,6 +169,66 @@
                 )
         doc.multiBuild(makeStory())
 
+class RenderMeasuringPara:
+    def __init__(self,canv,style,aW,measuring=True,annotations=[],errors=[],length_errors=[],onDrawFuncName='_onDrawFunc'):
+        ends = []
+        def _onDrawFunc(canv,name,label):
+            if measuring and label=='end':
+                ends.append(canv._curr_tx_info)
+            annotations.append(canv._curr_tx_info)
+        setattr(canv,onDrawFuncName,_onDrawFunc)
+        self._state = (canv,style,aW,measuring,ends,annotations,errors,length_errors)
+        if measuring:
+            self._end = ('<ondraw name="%s" label="end"/>' % onDrawFuncName)
+
+    def __call__(self,x,y,text,wc,ns,n,hrep=' ',crep=' ',hdw=0,cdw=0):
+        canv,style,aW,measuring,ends,annotations,errors,length_errors = self._state
+        if measuring: text += self._end
+        if '{H}' in text:
+            text = text.replace('{H}',hrep)
+            wc += hdw
+        if '{C}' in text:
+            text = text.replace('{C}',crep)
+            wc += cdw
+        p = Paragraph(text,style)
+        w,h = p.wrap(aW,1000)
+        annotations[:] = []
+        if measuring:
+            ends[:] = []
+        p.drawOn(canv,x,y-h)
+        canv.saveState()
+        canv.setLineWidth(0.1)
+        canv.setStrokeColorRGB(1,0,0)
+        canv.rect(x,y-h,wc,h)
+
+        if n is not None:
+            canv.setFillColorRGB(0,1,0)
+            canv.drawRightString(x,y-h,'%3d: ' % n)
+
+        if annotations:
+            canv.setLineWidth(0.1)
+            canv.setStrokeColorRGB(0,1,0)
+            canv.setFillColorRGB(0,0,1)
+            canv.setFont('Helvetica',0.2)
+            for info in annotations:
+                cur_x = info['cur_x']+x
+                cur_y = info['cur_y']+y-h
+                canv.drawCentredString(cur_x, cur_y+0.3,'%.2f' % (cur_x-x))
+                canv.line(cur_x,cur_y,cur_x,cur_y+0.299)
+        if measuring:
+            if not ends:
+                errors.append('Paragraph measurement failure no ends found for %s\n%r' % (ns,text))
+            elif len(ends)>1:
+                errors.append('Paragraph measurement failure no len(ends)==%d for %s\n%r' % (len(ends),ns,text))
+            else:
+                cur_x = ends[0]['cur_x']
+                adiff = abs(wc-cur_x)
+                length_errors.append(adiff)
+                if adiff>1e-8:
+                    errors.append('Paragraph measurement error wc=%.4f measured=%.4f for %s\n%r' % (wc,cur_x,ns,text))
+        canv.restoreState()
+        return h
+
 class BreakingTestCase(unittest.TestCase):
     "Test multi-page splitting of paragraphs (eyeball-test)."
     def test0(self):
@@ -241,51 +301,6 @@
         x = stringWidth('999: ','Courier',bfs) + 5
         aW = int(pageWidth)-2*x
 
-        def doPara(x,text,wc,ns,n,hrep=' ',crep=' ',hdw=0,cdw=0):
-            if '{H}' in text:
-                text = text.replace('{H}',hrep)
-                wc += hdw
-            if '{C}' in text:
-                text = text.replace('{C}',crep)
-                wc += cdw
-            p = Paragraph(text,bt)
-            w,h = p.wrap(aW,1000)
-            annotations[:] = []
-            if measuring:
-                ends[:] = []
-            p.drawOn(canv,x,y-h)
-            canv.saveState()
-            canv.setLineWidth(0.1)
-            canv.setStrokeColorRGB(1,0,0)
-            canv.rect(x,y-h,wc,h)
-
-            if n is not None:
-                canv.setFillColorRGB(0,1,0)
-                canv.drawRightString(x,y-h,'%3d: ' % n)
-
-            if annotations:
-                canv.setLineWidth(0.1)
-                canv.setStrokeColorRGB(0,1,0)
-                canv.setFillColorRGB(0,0,1)
-                canv.setFont('Helvetica',0.2)
-                for info in annotations:
-                    cur_x = info['cur_x']+x
-                    cur_y = info['cur_y']+y-h
-                    canv.drawCentredString(cur_x, cur_y+0.3,'%.2f' % (cur_x-x))
-                    canv.line(cur_x,cur_y,cur_x,cur_y+0.299)
-            if measuring:
-                if not ends:
-                    errors.append('Paragraph measurement failure no ends found for %s\n%r' % (ns,text))
-                elif len(ends)>1:
-                    errors.append('Paragraph measurement failure no len(ends)==%d for %s\n%r' % (len(ends),ns,text))
-                else:
-                    cur_x = ends[0]['cur_x']
-                    adiff = abs(wc-cur_x)
-                    length_errors.append(adiff)
-                    if adiff>1e-8:
-                        errors.append('Paragraph measurement error wc=%.4f measured=%.4f for %s\n%r' % (wc,cur_x,ns,text))
-            canv.restoreState()
-            return h
         swc = lambda t: stringWidth(t,'Courier',bfs)
         swcbo = lambda t: stringWidth(t,'Courier-BoldOblique',bfs)
         swh = lambda t: stringWidth(t,'Helvetica',bfs)
@@ -358,37 +373,33 @@
         x3 = x2 + max(_tmp[2]+10+gex(2,_tmp[0]) for _tmp in data) + 5
         x4 = x3 + max(_tmp[2]+20+gex(3,_tmp[0]) for _tmp in data) + 5
         annotations = []
-        ends = []
         errors = []
         measuring = True
         length_errors = []
-        def _onDrawFunc(canv,name,label):
-            if measuring and label=='end':
-                ends.append(canv._curr_tx_info)
-            annotations.append(canv._curr_tx_info)
-        canv._onDrawFunc = _onDrawFunc
+        onDrawFuncName = '_onDrawFunc'
+        doPara = RenderMeasuringPara(canv,bt,aW,measuring,annotations,errors,length_errors,onDrawFuncName)
 
-        rep0 = '<ondraw name="_onDrawFunc"/>\\1'
+        rep0 = '<ondraw name="%s"/>\\1' % onDrawFuncName
         for n,text,wc in data:
             if argv and str(n) not in argv: continue
-            text0 = (apat.sub(rep0,text) if rep0 else text)+('<ondraw name="_onDrawFunc" label="end"/>' if measuring else '')
+            text0 = (apat.sub(rep0,text) if rep0 else text)
             ns = str(n)
-            h = doPara(x,text0,wc,ns,n)
+            h = doPara(x,y,text0,wc,ns,n)
             if '<a' in text:
                 text1 = apat.sub('<img width="10" height="5" src="pythonpowered.gif"/>',text0)
-                doPara(x1,text1,wc+10+gex(1,n),ns+'.11',None)
+                doPara(x1,y,text1,wc+10+gex(1,n),ns+'.11',None)
                 text2 = apat.sub('\\1<img width="10" height="5" src="pythonpowered.gif"/>',text0)
-                doPara(x2,text1,wc+10+gex(2,n),ns+'.12',None)
+                doPara(x2,y,text1,wc+10+gex(2,n),ns+'.12',None)
                 text3 = apat.sub('\\1<img width="10" height="5" src="pythonpowered.gif"/><img width="10" height="5" src="pythonpowered.gif"/>\\1',text0)
-                doPara(x3,text3,wc+20+gex(3,n),ns+'.13',None)
-                doPara(x4,text3,wc+20+gex(3,n),ns+'.14',None,
+                doPara(x3,y,text3,wc+20+gex(3,n),ns+'.13',None)
+                doPara(x4,y,text3,wc+20+gex(3,n),ns+'.14',None,
                         hrep='<span face="Courier-BoldOblique"> </span>',
                         crep='<span face="Helvetica-BoldOblique"> </span>',
                         hdw = swcbo(' ') - swhbo(' '),
                         cdw = swhbo(' ') - swcbo(' '),
                         )
             else:
-                doPara(x1,text0,wc,ns+'.21',None,
+                doPara(x1,y,text0,wc,ns+'.21',None,
                         hrep='<span face="Courier-BoldOblique"> </span>',
                         crep='<span face="Helvetica-BoldOblique"> </span>',
                         hdw = swcbo(' ') - swhbo(' '),
@@ -432,6 +443,42 @@
                 )
             doc.build(story)
 
+    def test6(self):
+        """test of single/multi-frag text and shrinkSpace calculation"""
+        pagesize = (200+20, 400)
+        canv = Canvas(outputfile('test_platypus_breaking_lelegaifax.pdf'), pagesize=pagesize)
+        f = Frame(10, 0, 200, 400,
+                  showBoundary=ShowBoundaryValue(dashArray=(1,1)),
+                  leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0)
+        style = ParagraphStyle(
+                                'normal',
+                                fontName='Helvetica',
+                                fontSize=11.333628,
+                                spaceBefore=20,
+                                hyphenationLang='en-US',
+                                alignment=TA_JUSTIFY,
+                                spaceShrinkage=0.05,
+                                )
+        text1 = """My recent use case was the preparation"""
+
+        text2 = """<span color='red'>My </span> recent use case was the preparation"""
+        ix0 = len(canv._code)
+        f.addFromList([Paragraph(text1, style),
+                   Paragraph(text2, style),
+                   ],
+                  canv)
+        self.assertEqual(canv._code[ix0:],
+                ['q', '0 0 0 RG', '.1 w', '[1 1] 0 d', 'n 10 0 200 400 re S', 'Q', 'q', '1 0 0 1 10 388 cm',
+                    'q', '0 0 0 rg',
+                    'BT 1 0 0 1 0 .666372 Tm /F1 11.33363 Tf 12 TL -0.157537 Tw'
+                    ' (My recent use case was the preparation) Tj T* 0 Tw ET',
+                    'Q', 'Q', 'q', '1 0 0 1 10 356 cm', 'q',
+                    'BT 1 0 0 1 0 .666372 Tm -0.157537 Tw 12 TL /F1 11.33363 Tf 1 0 0 rg'
+                    ' (My ) Tj 0 0 0 rg (recent use case was the preparation) Tj T* 0 Tw ET', 'Q', 'Q'],
+                'Lele Gaifax bug example did not produce the right code',
+                )
+        canv.showPage()
+        canv.save()
 
 def makeSuite():
     return makeSuiteForClasses(BreakingTestCase)