Skip to content

Commit b52f9ea

Browse files
Merge pull request #230 from AnEnglishmanInNorway/testdecorations
Implement borders with gaps. Implement shadows, except blur, on pictures (but not on text) Introduce first test for decorations (borders/shadows)
2 parents d21104b + 0ebeacd commit b52f9ea

23 files changed

Lines changed: 1087 additions & 232 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,10 @@ singlePageNumberPosition = right
119119
# This has no effect on a single page width run, where the inside cover pages are simply omitted
120120
insideCoverWhite = False
121121
122+
# Shadows were implemented in May 2025 (except blur) but can be turned off
123+
# Default False, if True then no shadows are created on objects
124+
noShadows = False
125+
122126
# These possibilities are seldom needed in the latest versions of the program
123127
#extraBackgroundFolders =
124128
# ${PROGRAMDATA}/hps/${KEYACCOUNT}/addons/447/backgrounds/v1/backgrounds

cewe2pdf.py

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,14 @@
7676

7777
from pathlib import Path
7878

79+
import reportlab.lib.colors
7980
import reportlab.lib.pagesizes
8081
from reportlab.lib.utils import ImageReader
8182
from reportlab.pdfgen import canvas
8283
from reportlab.platypus import Paragraph, Table
8384
# from reportlab.lib.styles import getSampleStyleSheet
8485

86+
import numpy as np
8587
import PIL
8688

8789
from 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+
476596
def 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

cewe2pdf.pyproj

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@
131131
<Content Include="tests\testClipart\additional_fonts.txt" />
132132
<Content Include="tests\testClipart\previous_result_pdfs\testClipart.mcf.20250326S.pdf" />
133133
<Content Include="tests\testClipart\previous_result_pdfs\testClipart.mcf.20250412S.pdf" />
134+
<Content Include="tests\testDecorations\additional_fonts.txt" />
135+
<Content Include="tests\testDecorations\cewe2pdf.ini" />
136+
<Content Include="tests\testDecorations\test_decorations.mcf" />
137+
<Content Include="tests\TestDecorations\test_decorations_mcf-Dateien\folderid.xml" />
138+
<Content Include="tests\TestDecorations\test_decorations_mcf-Dateien\folderid.xml~" />
139+
<Content Include="tests\TestDecorations\test_decorations_mcf-Dateien\z31qhpgx_1_greysquareblueborder.jpg" />
134140
<Content Include="tests\testEmptyPageOne\additional_fonts.txt" />
135141
<Content Include="tests\testEmptyPageOne\previous_result_pdfs\test_emptyPageOne.mcf.20250321D.pdf" />
136142
<Content Include="tests\testfontsubstitution\additional_fonts.txt" />
@@ -141,7 +147,6 @@
141147
<Content Include="tests\testPageNumbers\additional_fonts.txt" />
142148
<Content Include="tests\testPageNumbers\cewe2pdf.ini" />
143149
<Content Include="tests\testPageNumbers\test_pagenumbers.mcf" />
144-
<Content Include="tests\testPageNumbers\test_pagenumbers.mcf~" />
145150
<Content Include="tests\testPageNumbers\test_pagenumbers_mcf-Dateien\folderid.xml" />
146151
<Content Include="tests\testPageNumbers\test_pagenumbers_mcf-Dateien\folderid.xml~" />
147152
<Content Include="tests\unittest_fotobook\additional_fonts.txt" />
@@ -275,6 +280,7 @@
275280
<Compile Include="tests\testClipartColorReplacement\test_clipartColorReplacement.py" />
276281
<Compile Include="tests\testbackgrounds\test_backgrounds.py" />
277282
<Compile Include="tests\testClipart\test_drawClipart.py" />
283+
<Compile Include="tests\testDecorations\test_decorations.py" />
278284
<Compile Include="tests\testEmptyPageOne\test_emptyPageOne.py" />
279285
<Compile Include="tests\testFontDoesNotExist\test_fontDoesNotExist.py" />
280286
<Compile Include="tests\testFontSubstitution\test_fontSubstitution.py" />
@@ -324,6 +330,9 @@
324330
<Folder Include="tests\testbackgrounds\previous_result_pdfs\" />
325331
<Folder Include="tests\testClipartColorReplacement\previous_result_pdfs\" />
326332
<Folder Include="tests\testClipart\previous_result_pdfs\" />
333+
<Folder Include="tests\testDecorations\" />
334+
<Folder Include="tests\TestDecorations\previous_result_pdfs\" />
335+
<Folder Include="tests\TestDecorations\test_decorations_mcf-Dateien\" />
327336
<Folder Include="tests\testEmptyPageOne\previous_result_pdfs\" />
328337
<Folder Include="tests\testfontsubstitution\" />
329338
<Folder Include="tests\testFontSubstitution\previous_result_pdfs\" />

tests/testClipart/test_drawClipart.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,18 +66,25 @@ def tryToBuildBook(inFile, outFile, latestResultFile, keepDoublePages):
6666

6767
#os.remove(outFile)
6868

69-
def test_testDrawClipart():
69+
def defineCommonVariables():
7070
albumFolderBasename = 'testClipart'
7171
albumBasename = "testClipart"
7272
inFile = str(Path(Path.cwd(), 'tests', f"{albumFolderBasename}", f'{albumBasename}.mcf'))
7373
yyyymmdd = datetime.today().strftime("%Y%m%d")
74+
return albumFolderBasename,albumBasename,inFile,yyyymmdd
7475

76+
def test_testDrawClipart(main=False):
77+
albumFolderBasename, albumBasename, inFile, yyyymmdd = defineCommonVariables()
7578
styleid = "S"
76-
outFileBasename = f'{albumBasename}.mcf.{yyyymmdd}{styleid}.pdf'
79+
if (main):
80+
# use an undated output file name when running as main rather than via pytest
81+
outFileBasename = f'{albumBasename}.mcf.pdf'
82+
else:
83+
outFileBasename = f'{albumBasename}.mcf.{yyyymmdd}{styleid}.pdf'
7784
outFile = str(Path(Path.cwd(), 'tests', f"{albumFolderBasename}", outFileBasename))
7885
latestResultFile = getLatestResultFile(albumFolderBasename, f"*{styleid}.pdf")
7986
tryToBuildBook(inFile, outFile, latestResultFile, False)
8087

8188
if __name__ == '__main__':
8289
#only executed when this file is run directly.
83-
test_testDrawClipart()
90+
test_testDrawClipart(main=True)

tests/testClipartColorReplacement/test_clipartColorReplacement.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,19 +46,26 @@ def tryToBuildBook(inFile, outFile, latestResultFile, keepDoublePages, expectedP
4646
#os.remove(outFile)
4747

4848

49-
def test_testClipartColorReplacement():
49+
def defineCommonVariables():
5050
albumFolderBasename = 'testClipartColorReplacement'
5151
albumBasename = "test_clipart_colorreplacement"
5252
inFile = str(Path(Path.cwd(), 'tests', f"{albumFolderBasename}", f'{albumBasename}.mcf'))
5353
yyyymmdd = datetime.today().strftime("%Y%m%d")
54+
return albumFolderBasename,albumBasename,inFile,yyyymmdd
5455

56+
def test_testClipartColorReplacement(main=False):
57+
albumFolderBasename, albumBasename, inFile, yyyymmdd = defineCommonVariables()
5558
styleid = "S"
56-
outFileBasename = f'{albumBasename}.mcf.{yyyymmdd}{styleid}.pdf'
59+
if (main):
60+
# use an undated output file name when running as main rather than via pytest
61+
outFileBasename = f'{albumBasename}.mcf.pdf'
62+
else:
63+
outFileBasename = f'{albumBasename}.mcf.{yyyymmdd}{styleid}.pdf'
5764
outFile = str(Path(Path.cwd(), 'tests', f"{albumFolderBasename}", outFileBasename))
5865
latestResultFile = getLatestResultFile(albumFolderBasename, f"*{styleid}.pdf")
5966
tryToBuildBook(inFile, outFile, latestResultFile, False, 28)
6067

6168

6269
if __name__ == '__main__':
6370
#only executed when this file is run directly.
64-
test_testClipartColorReplacement()
71+
test_testClipartColorReplacement(main=True)

tests/testDecorations/additional_fonts.txt

Whitespace-only changes.

tests/testDecorations/cewe2pdf.ini

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[DEFAULT]
2+
cewe_folder = tests/
3+
hpsFolder = tests/hps
4+
# noShadows = true
Binary file not shown.
Binary file not shown.
Binary file not shown.

0 commit comments

Comments
 (0)