7676
7777from pathlib import Path
7878
79+ import reportlab .lib .colors
7980import reportlab .lib .pagesizes
8081from reportlab .lib .utils import ImageReader
8182from reportlab .pdfgen import canvas
8283from reportlab .platypus import Paragraph , Table
8384# from reportlab.lib.styles import getSampleStyleSheet
8485
86+ import numpy as np
8587import PIL
8688
8789from packaging .version import parse as parse_version
@@ -390,6 +392,9 @@ def processAreaImageTag(imageTag, area, areaHeight, areaRot, areaWidth, imagedir
390392 pdf .translate (img_transx , transy ) # we need to go to the center for correct rotation
391393 pdf .rotate (- areaRot ) # rotation around center of area
392394
395+ for decorationTag in area .findall ('decoration' ):
396+ processDecorationShadow (decorationTag , areaHeight , areaWidth , pdf )
397+
393398 # calculate the non-symmetric shift of the center, given the left pos and the width.
394399 frameShiftX_mcf = - (frameDeltaX_mcfunit - ((areaWidth - imgCropWidth_mcfunit ) - frameDeltaX_mcfunit ))/ 2
395400 frameShiftY_mcf = (frameDeltaY_mcfunit - ((areaHeight - imgCropHeight_mcfunit ) - frameDeltaY_mcfunit ))/ 2
@@ -413,7 +418,7 @@ def processAreaImageTag(imageTag, area, areaHeight, areaRot, areaWidth, imagedir
413418 insertClipartFile (frameClipartFileName , colorreplacements , 0 , areaWidth , areaHeight , frameAlpha , pdf , 0 , 0 , False , False , None )
414419
415420 for decorationTag in area .findall ('decoration' ):
416- processAreaDecorationTag (decorationTag , areaHeight , areaWidth , pdf )
421+ processDecorationBorders (decorationTag , areaHeight , areaWidth , pdf )
417422
418423 pdf .rotate (areaRot )
419424 pdf .translate (- img_transx , - transy )
@@ -422,10 +427,9 @@ def processAreaImageTag(imageTag, area, areaHeight, areaRot, areaWidth, imagedir
422427 tempFileList .append (jpeg .name )
423428
424429
425- def processAreaDecorationTag (decoration , areaHeight , areaWidth , pdf ):
430+ def processDecorationBorders (decoration , areaHeight , areaWidth , pdf ):
426431 # Draw a single cell table to represent border decoration (a box around the object)
427432 # We assume that this is called from inside the rotation and translation operation
428-
429433 for border in decoration .findall ('border' ):
430434 if "enabled" in border .attrib :
431435 enabledAttrib = border .get ('enabled' )
@@ -444,14 +448,23 @@ def processAreaDecorationTag(decoration, areaHeight, areaWidth, pdf):
444448 bcolor = reportlab .lib .colors .HexColor (colorAttrib )
445449
446450 adjustment = 0
451+ gap = 0
452+ if "gap" in border .attrib :
453+ gapAttrib = border .get ('gap' )
454+ gap = mcf2rl * floor (float (gapAttrib ))
455+ # position possibilities are: outsideWithGap outside inside centered insideWithGap
447456 if "position" in border .attrib :
448457 positionAttrib = border .get ('position' )
458+ if positionAttrib == "insideWithGap" :
459+ adjustment = - bwidth * 0.5 - gap
449460 if positionAttrib == "inside" :
450461 adjustment = - bwidth * 0.5
451462 if positionAttrib == "centered" :
452463 adjustment = 0
453464 if positionAttrib == "outside" :
454465 adjustment = bwidth * 0.5
466+ if positionAttrib == "outsideWithGap" :
467+ adjustment = bwidth * 0.5 + gap
455468
456469 frameBottomLeft_x = - 0.5 * (mcf2rl * areaWidth ) - adjustment
457470 frameBottomLeft_y = - 0.5 * (mcf2rl * areaHeight ) - adjustment
@@ -473,6 +486,113 @@ def processAreaDecorationTag(decoration, areaHeight, areaWidth, pdf):
473486 frm_table .drawOn (pdf , frameBottomLeft_x , frameBottomLeft_y )
474487
475488
489+ def findShadowBottomLeft (frameBottomLeft , angle , distance , swidth ):
490+ x , y = frameBottomLeft
491+ if distance < 0.001 :
492+ # why on earth do I need this special case??
493+ return x - swidth / 2 , y - swidth / 2
494+ # Compute shadow shift vector
495+ angle_rad = np .radians (angle - 90 )
496+ shadow_dx = distance * np .cos (angle_rad )
497+ shadow_dy = - distance * np .sin (angle_rad ) # Flip the Y-axis direction
498+ # Return shadow rectangle bottom left coordinates
499+ return x + shadow_dx - swidth / 2 , y + shadow_dy - swidth / 2
500+
501+ def intensityToGrey (value ):
502+ colorComponentValue = 1 - (max (1 , min (255 , value )) / 255 )
503+ return reportlab .lib .colors .Color (colorComponentValue , colorComponentValue , colorComponentValue )
504+
505+ def processDecorationShadow (decoration , areaHeight , areaWidth , pdf ):
506+ if getConfigurationBool (defaultConfigSection , "noShadows" , "False" ):
507+ # shadows were implemented in May 2025. Prior to that, you could have specified
508+ # shadows on your photos for printing by CEWE but you would not have got them
509+ # in the pdf version. And this might be what you want, so there is an option
510+ # to stop shadow processing altogether
511+ return
512+
513+ # We assume that this is called from inside the rotation and translation operation
514+ # Ref https://cs.phx.photoprintit.com/hps-hilfe-online/no_no_5026/7.4/faq-fotos.html#q010
515+ # can't find an english version.
516+
517+ frameBottomLeft_x = - 0.5 * (mcf2rl * areaWidth )
518+ frameBottomLeft_y = - 0.5 * (mcf2rl * areaHeight )
519+ frameWidth = mcf2rl * areaWidth
520+ frameHeight = mcf2rl * areaHeight
521+
522+ for shadow in decoration .findall ('shadow' ):
523+ if "shadowEnabled" in shadow .attrib :
524+ enabledAttrib = shadow .get ('shadowEnabled' )
525+ if enabledAttrib != '1' :
526+ continue
527+
528+ # shadow width simulates "distance away of the light source". If the width is zero,
529+ # then the light rays are parallel and the shadow is the same size as the object.
530+ # So the width value is really about how much bigger the shadow is than the object.
531+ swidth = 1
532+ if "shadowWidthInMM" in shadow .attrib :
533+ widthAttrib = shadow .get ('shadowWidthInMM' )
534+ if widthAttrib is not None :
535+ swidth = mcf2rl * floor (float (widthAttrib ) * 10 ) # units are 1 mm not 0.1 mm!
536+
537+ # sdistance effectively moves the light source to the side of the centre, in the
538+ # direction of sangle. When sdistance is zero then sangle is irrelevant. When sdistance
539+ # is non-zero it offsets the shadow from the object, as determined by the sangle.
540+ sdistance = 10
541+ if "shadowDistance" in shadow .attrib :
542+ distanceAttrib = shadow .get ('shadowDistance' )
543+ if distanceAttrib is not None :
544+ sdistance = mcf2rl * floor (float (distanceAttrib ))
545+
546+ intensity = 128
547+ if "shadowIntensity" in shadow .attrib :
548+ intensityAttrib = shadow .get ('shadowIntensity' )
549+ if intensityAttrib is not None :
550+ intensity = int (intensityAttrib ) # range 1 .. 255, I think
551+
552+ # you might think that sangle is the angle of the light source, but it is actually
553+ # the angle where the shadow should appear (exactly 180 degrees opposite). Not
554+ # unreasonable, when swidth sets the width of the shadow rather than how far away
555+ # the light source is. I guess the theory is that the users are not physicists!
556+ sangle = 135
557+ if "shadowAngle" in shadow .attrib :
558+ angleAttrib = shadow .get ('shadowAngle' )
559+ if angleAttrib is not None :
560+ sangle = floor (float (angleAttrib ))
561+ if sangle < 0.0 : # mcf range -179 .. +180
562+ sangle = sangle + 360 # range 0 .. 359
563+
564+ shadowBottomLeft_x , shadowBottomLeft_y = \
565+ findShadowBottomLeft ((frameBottomLeft_x , frameBottomLeft_y ), sangle , sdistance , swidth )
566+ shadowWidth = frameWidth + swidth
567+ shadowHeight = frameHeight + swidth
568+ shadowColor = intensityToGrey (intensity ) # reportlab.lib.colors.grey
569+
570+ frm_table = Table (
571+ data = [[None ]],
572+ colWidths = shadowWidth ,
573+ rowHeights = shadowHeight ,
574+ style = [
575+ # The two (0, 0) in each attribute represent the range of table cells that the style applies to.
576+ # Since there's only one cell at (0, 0), it's used for both start and end of the range
577+ ('ALIGN' , (0 , 0 ), (0 , 0 ), 'CENTER' ),
578+ ('BACKGROUND' , (0 , 0 ), (0 , 0 ), shadowColor ),
579+ ('VALIGN' , (0 , 0 ), (0 , 0 ), 'MIDDLE' ),
580+ ]
581+ )
582+ frm_table .wrapOn (pdf , shadowWidth , shadowHeight )
583+ frm_table .drawOn (pdf , shadowBottomLeft_x , shadowBottomLeft_y )
584+
585+ def warnAndIgnoreEnabledDecorationShadow (decoration ):
586+ if getConfigurationBool (defaultConfigSection , "noShadows" , "False" ):
587+ return
588+ for shadow in decoration .findall ('shadow' ):
589+ if "shadowEnabled" in shadow .attrib :
590+ enabledAttrib = shadow .get ('shadowEnabled' )
591+ if enabledAttrib == '1' :
592+ logging .warning ("Ignoring shadow specified on text, that is not implemented!" )
593+ continue
594+
595+
476596def processAreaTextTag (textTag , additional_fonts , area , areaHeight , areaRot , areaWidth , pdf , transx , transy ): # noqa: C901 (too complex)
477597 # note: it would be better to use proper html processing here
478598 htmlxml = etree .XML (textTag .text )
@@ -518,6 +638,10 @@ def processAreaTextTag(textTag, additional_fonts, area, areaHeight, areaRot, are
518638 pdf .translate (transx , transy )
519639 pdf .rotate (- areaRot )
520640
641+ # we don't do shadowing on texts, but we could at least warn about that...
642+ for decorationTag in area .findall ('decoration' ):
643+ warnAndIgnoreEnabledDecorationShadow (decorationTag )
644+
521645 # Get the background color. It is stored in an extra element.
522646 backgroundColor = None
523647 backgroundColorAttrib = area .get ('backgroundcolor' )
@@ -640,7 +764,9 @@ def processAreaTextTag(textTag, additional_fonts, area, areaHeight, areaRot, are
640764 except Exception :
641765 logging .exception ('Exception' )
642766
643- # Add a frame object that can contain multiple paragraphs
767+ # Add a frame object that can contain multiple paragraphs. Margins (padding) are specified in
768+ # the editor in mm, arriving in the mcf in 1/10 mm, but appearing in the html with the unit "px".
769+ # This is a bit strange, but ignoring the "px" and using mcf2rl seems to work ok.
644770 leftPad = mcf2rl * tablelmarg
645771 rightPad = mcf2rl * tablermarg
646772 bottomPad = mcf2rl * tablebmarg
@@ -699,7 +825,7 @@ def processAreaTextTag(textTag, additional_fonts, area, areaHeight, areaRot, are
699825 newFrame .addFromList (pdf_flowableList , pdf )
700826
701827 for decorationTag in area .findall ('decoration' ):
702- processAreaDecorationTag (decorationTag , areaHeight , areaWidth , pdf )
828+ processDecorationBorders (decorationTag , areaHeight , areaWidth , pdf )
703829
704830 pdf .rotate (areaRot )
705831 pdf .translate (- transx , - transy )
@@ -759,7 +885,7 @@ def insertClipartFile(fileName:str, colorreplacements, transx, areaWidth, areaHe
759885 mcf2rl * - 0.5 * areaWidth , mcf2rl * - 0.5 * areaHeight ,
760886 width = mcf2rl * areaWidth , height = mcf2rl * areaHeight , mask = 'auto' )
761887 if decoration is not None :
762- processAreaDecorationTag (decoration , areaHeight , areaWidth , pdf )
888+ processDecorationBorders (decoration , areaHeight , areaWidth , pdf )
763889 pdf .rotate (areaRot )
764890 pdf .translate (- img_transx , - transy )
765891
0 commit comments