diff --git a/clipart/areas.py b/clipart/areas.py index ad580983..4def56a0 100644 --- a/clipart/areas.py +++ b/clipart/areas.py @@ -2,16 +2,25 @@ import logging +from PIL import Image from reportlab.lib.utils import ImageReader from .catalog import getClipConfig, loadClipart +from conversionState import ConversionState from renderContext import RenderContext +def _hasEnabledShadow(decoration): + """Return whether ``decoration`` contains a CEWE-enabled shadow.""" + return (decoration is not None + and any(shadow.get('shadowEnabled') in (None, '1') + for shadow in decoration.findall('shadow'))) + + def processAreaClipartTag(clipartElement, areaHeight, areaRot, areaWidth, pdf, transx, transy, clipArtDecoration, context: RenderContext, - borderProcessor): - """Render one clipart area and its optional border.""" + state: ConversionState, shadowProcessor, borderProcessor): + """Render one clipart area and its optional decorations.""" clipartID = int(clipartElement.get('designElementId')) if clipartID == 0: return @@ -29,11 +38,13 @@ def processAreaClipartTag(clipartElement, areaHeight, areaRot, areaWidth, pdf, t colorReplacements, flipX, flipY = getClipConfig(clipartElement) insertClipartFile(fileName, colorReplacements, transx, areaWidth, areaHeight, alpha, pdf, - transy, areaRot, flipX, flipY, clipArtDecoration, context, borderProcessor) + transy, areaRot, flipX, flipY, clipArtDecoration, context, state, + shadowProcessor, borderProcessor) def insertClipartFile(fileName, colorReplacements, transx, areaWidth, areaHeight, alpha, pdf, transy, areaRot, flipX, flipY, decoration, context: RenderContext, + state: ConversionState | None = None, shadowProcessor=None, borderProcessor=None): """Rasterise a clipart file and draw it at the supplied area geometry.""" newWidth = int(0.5 + areaWidth * context.image_resolution / 254.0) @@ -48,9 +59,19 @@ def insertClipartFile(fileName, colorReplacements, transx, areaWidth, areaHeight clipart.replaceColors(colorReplacements) clipart.convertToPngInBuffer(newWidth, newHeight, alpha, flipX, flipY) + clipartImage = None + if _hasEnabledShadow(decoration) and shadowProcessor is not None and state is not None: + # Use the rasterised SVG's alpha silhouette. In particular, open + # artwork (for example an arrow) must not cast a rectangular shadow. + clipartImage = Image.open(clipart.pngMemFile).convert('RGBA') + clipartImage.load() + clipart.pngMemFile.seek(0) logging.debug(f"Clipart file: {fileName}") pdf.translate(transx, transy) pdf.rotate(-areaRot) + if clipartImage is not None: + shadowProcessor(decoration, areaHeight, areaWidth, pdf, context, state, + clipartImage, areaWidth, areaHeight, areaRotation=areaRot) pdf.drawImage(ImageReader(clipart.pngMemFile), context.mcf_to_reportlab * -0.5 * areaWidth, context.mcf_to_reportlab * -0.5 * areaHeight, diff --git a/decorations/shadows.py b/decorations/shadows.py index 8d41a7ed..7c350e71 100644 --- a/decorations/shadows.py +++ b/decorations/shadows.py @@ -53,10 +53,11 @@ def drawBlurredImageShadow(pdf, im, imgCropWidth_mcfunit, # was downsampled earlier in processAreaImageTag. pixelsPerMcfunit = im.width / imgCropWidth_mcfunit - # CEWE's displayed shadow expands by about three tenths of the nominal - # width on each side. This was calibrated against the width-only test page - # (0 .. 10 mm); the old ReportLab Table is noticeably larger at high values. - spreadRadius_px = int(round( + # CEWE's displayed shadow protrudes by about three tenths of the nominal + # width on each side. It enlarges the shadow's bounding box rather than + # thickening every opaque pixel. The distinction is invisible for a + # solid rectangle but essential for open artwork such as arrows. + spreadOutset_px = int(round( shadowWidth_mcfunit * pixelsPerMcfunit * 0.30 )) @@ -68,18 +69,22 @@ def drawBlurredImageShadow(pdf, im, imgCropWidth_mcfunit, # Keep the fractional radius: rounding it would make several of CEWE's # small blur settings render identically at the configured image DPI. blurRadius_px = shadowBlur_mcfunit * pixelsPerMcfunit * 0.5 - padding_px = spreadRadius_px + int(np.ceil(3 * blurRadius_px)) + blurPadding_px = int(np.ceil(3 * blurRadius_px)) + padding_px = spreadOutset_px + blurPadding_px alpha = im.getchannel('A') shadowAlpha = Image.new( 'L', (im.width + 2 * padding_px, im.height + 2 * padding_px), 0 ) - shadowAlpha.paste(alpha, (padding_px, padding_px)) - - if spreadRadius_px > 0: - shadowAlpha = shadowAlpha.filter( - ImageFilter.MaxFilter(2 * spreadRadius_px + 1) - ) + if spreadOutset_px > 0: + # Put a scaled copy of the alpha silhouette into the enlarged shadow + # bounds. A morphological max filter would make an open SVG's thin + # strokes dramatically fatter, unlike CEWE's ``Utstikk`` control. + alpha = alpha.resize( + (im.width + 2 * spreadOutset_px, + im.height + 2 * spreadOutset_px), + Image.Resampling.LANCZOS) + shadowAlpha.paste(alpha, (blurPadding_px, blurPadding_px)) if blurRadius_px > 0: shadowAlpha = shadowAlpha.filter(ImageFilter.GaussianBlur(blurRadius_px)) diff --git a/pageElements.py b/pageElements.py index aa3f8f3a..302cfdc3 100644 --- a/pageElements.py +++ b/pageElements.py @@ -119,7 +119,8 @@ def processElements(additional_fonts, fotobook, imagedir, for clipartElement in area.findall('clipart'): processAreaClipartTag( clipartElement, areaHeight, areaRot, areaWidth, pdf, - transCx, transCy, decoration, context, + transCx, transCy, decoration, context, state, + processDecorationShadow, lambda decoration, height, width, canvas: processDecorationBorders(decoration, height, width, canvas, context)) diff --git a/tests/testCorners/previous_result_pdfs/testCorners.mcf.20261020S.pdf b/tests/testCorners/previous_result_pdfs/testCorners.mcf.20261020S.pdf new file mode 100644 index 00000000..b7e855cc Binary files /dev/null and b/tests/testCorners/previous_result_pdfs/testCorners.mcf.20261020S.pdf differ diff --git a/tests/testDecorations/previous_result_pdfs/test_decorations.mcf.20261020S.pdf b/tests/testDecorations/previous_result_pdfs/test_decorations.mcf.20261020S.pdf new file mode 100644 index 00000000..b5f28480 Binary files /dev/null and b/tests/testDecorations/previous_result_pdfs/test_decorations.mcf.20261020S.pdf differ diff --git a/tests/testDecorations/test_decorations.mcf b/tests/testDecorations/test_decorations.mcf index 43190b3b..2828c673 100644 --- a/tests/testDecorations/test_decorations.mcf +++ b/tests/testDecorations/test_decorations.mcf @@ -1,16 +1,17 @@ - - - + + + + - - + + @@ -20,26 +21,29 @@ -

Front cover

]]> - +

Front cover

]]> +
-


]]> - +
-

Back cover

]]> - +

Back cover

]]> +
@@ -61,8 +65,8 @@ p, li { white-space: pre-wrap; } -

Actual first page

]]> - +

Actual first page

]]> +
@@ -84,7 +88,7 @@ p, li { white-space: pre-wrap; }
- + @@ -94,7 +98,7 @@ p, li { white-space: pre-wrap; } - + @@ -104,7 +108,7 @@ p, li { white-space: pre-wrap; } - + @@ -114,7 +118,7 @@ p, li { white-space: pre-wrap; } - + @@ -124,7 +128,7 @@ p, li { white-space: pre-wrap; } - + @@ -134,7 +138,7 @@ p, li { white-space: pre-wrap; } - + @@ -168,7 +172,7 @@ p, li { white-space: pre-wrap; } - + @@ -178,7 +182,7 @@ p, li { white-space: pre-wrap; } - + @@ -188,7 +192,7 @@ p, li { white-space: pre-wrap; } - + @@ -198,7 +202,7 @@ p, li { white-space: pre-wrap; } - + @@ -209,7 +213,7 @@ p, li { white-space: pre-wrap; } - + @@ -220,7 +224,7 @@ p, li { white-space: pre-wrap; } - + @@ -231,7 +235,7 @@ p, li { white-space: pre-wrap; } - + @@ -241,7 +245,7 @@ p, li { white-space: pre-wrap; } - + @@ -251,7 +255,7 @@ p, li { white-space: pre-wrap; } - + @@ -261,7 +265,7 @@ p, li { white-space: pre-wrap; } - + @@ -271,35 +275,35 @@ p, li { white-space: pre-wrap; } - + -

Shadows with various distance, width, angle

]]> - +

Shadows with various distance, width, angle

]]> +
-

Check examples of rotation with borders and shadows

]]> - +

Check examples of rotation with borders and shadows

]]> +
-

Five different border placements

]]> - +

Five different border placements

]]> +
-

Different intensities, all else the same

]]> - +

Different intensities, all else the same

]]> +
@@ -319,8 +323,8 @@ p, li { white-space: pre-wrap; } -

The quick brown dog jumped over the lazy fox

]]> - +

The quick brown dog jumped over the lazy fox

]]> +
@@ -328,8 +332,8 @@ p, li { white-space: pre-wrap; } -

The quick brown dog jumped over the lazy fox

]]> - +

The quick brown dog jumped over the lazy fox

]]> +
@@ -337,8 +341,8 @@ p, li { white-space: pre-wrap; } -

The quick brown dog jumped over the lazy fox

]]> - +

The quick brown dog jumped over the lazy fox

]]> +
@@ -346,8 +350,8 @@ p, li { white-space: pre-wrap; } -

The quick brown dog jumped over the lazy fox

]]> - +

The quick brown dog jumped over the lazy fox

]]> +
@@ -355,8 +359,8 @@ p, li { white-space: pre-wrap; } -

The quick brown dog jumped over the lazy fox

]]> - +

The quick brown dog jumped over the lazy fox

]]> +
@@ -364,8 +368,8 @@ p, li { white-space: pre-wrap; } -

The 5 different border positions used on text

* Outside with spacing

* Outside

* Middle

* Inside

* Inside with spacing


]]> - +

The 5 different border positions used on text

* Outside with spacing

* Outside

* Middle

* Inside

* Inside with spacing


]]> +
@@ -373,17 +377,17 @@ p, li { white-space: pre-wrap; } -

Text centred in a frame

]]> - +

Text centred in a frame

]]> +
- + -

We don't do text shadowing!

]]> - +

Text shadowing works, Oct 2026!

]]> +
@@ -391,8 +395,8 @@ p, li { white-space: pre-wrap; } -

The quick brown dog jumped over the lazy foxy

]]> - +

The quick brown dog jumped over the lazy foxy

]]> +
@@ -400,8 +404,8 @@ p, li { white-space: pre-wrap; } -

Border defined "outside" and

text defined with an edge distance seems to make most sense

]]> - +

Border defined "outside" and

text defined with an edge distance seems to make most sense

]]> +
@@ -541,8 +545,8 @@ p, li { white-space: pre-wrap; } -

Actual last page

]]> - +

Actual last page

]]> +
@@ -553,9 +557,7 @@ p, li { white-space: pre-wrap; } - - - + - +
diff --git a/tests/testDecorations/test_decorations_mcf-Dateien/folderid.xml b/tests/testDecorations/test_decorations_mcf-Dateien/folderid.xml index a93d8998..dc648575 100644 --- a/tests/testDecorations/test_decorations_mcf-Dateien/folderid.xml +++ b/tests/testDecorations/test_decorations_mcf-Dateien/folderid.xml @@ -1,2 +1,2 @@ - + diff --git a/tests/testPageNumbers/test_pagenumbers.mcf b/tests/testPageNumbers/test_pagenumbers.mcf index a79e33d4..def17a65 100644 --- a/tests/testPageNumbers/test_pagenumbers.mcf +++ b/tests/testPageNumbers/test_pagenumbers.mcf @@ -1,51 +1,53 @@ - - - + + + + - - + + + + + + + +

Front cover

]]> + +
+ -


]]> - -
- - - - - - - -

Front cover

]]> - +
- -

Back cover

]]> - +

Back cover

]]> +
@@ -68,10 +70,9 @@ p, li { white-space: pre-wrap; } - -

Actual first page

]]> - +

Actual first page

]]> +
@@ -282,10 +283,9 @@ p, li { white-space: pre-wrap; } - -

Actual last page

]]> - +

Actual last page

]]> +
@@ -296,5 +296,5 @@ p, li { white-space: pre-wrap; } - +
diff --git a/tests/testPageNumbers/test_pagenumbers_mcf-Dateien/folderid.xml b/tests/testPageNumbers/test_pagenumbers_mcf-Dateien/folderid.xml index d3a48ad7..e6f0643f 100644 --- a/tests/testPageNumbers/test_pagenumbers_mcf-Dateien/folderid.xml +++ b/tests/testPageNumbers/test_pagenumbers_mcf-Dateien/folderid.xml @@ -1,2 +1,2 @@ - + diff --git a/tests/testShadows/previous_result_pdfs/test_shadows.mcf.20261020S.pdf b/tests/testShadows/previous_result_pdfs/test_shadows.mcf.20261020S.pdf new file mode 100644 index 00000000..f5d3dd10 Binary files /dev/null and b/tests/testShadows/previous_result_pdfs/test_shadows.mcf.20261020S.pdf differ diff --git a/tests/testShadows/test_shadows.mcf b/tests/testShadows/test_shadows.mcf index f84f175b..936a3067 100644 --- a/tests/testShadows/test_shadows.mcf +++ b/tests/testShadows/test_shadows.mcf @@ -1,7 +1,7 @@ - + - + @@ -10,7 +10,7 @@ - + @@ -18,6 +18,13 @@ + + + +

Front cover

]]> + +
+ @@ -32,13 +39,6 @@ li.checked::marker { content: "\2612"; } - - - -

Front cover

]]> - -
- @@ -140,9 +140,9 @@ li.checked::marker { content: "\2612"; } - + @@ -156,9 +156,9 @@ li.checked::marker { content: "\2612"; } - +
@@ -172,9 +172,9 @@ li.checked::marker { content: "\2612"; } - +
@@ -188,9 +188,9 @@ li.checked::marker { content: "\2612"; } - +
@@ -204,9 +204,9 @@ li.checked::marker { content: "\2612"; } - + @@ -220,9 +220,9 @@ li.checked::marker { content: "\2612"; } - + @@ -364,9 +364,9 @@ li.checked::marker { content: "\2612"; } - + @@ -380,9 +380,9 @@ li.checked::marker { content: "\2612"; } - + @@ -396,9 +396,9 @@ li.checked::marker { content: "\2612"; } - + @@ -412,9 +412,9 @@ li.checked::marker { content: "\2612"; } - + @@ -428,9 +428,9 @@ li.checked::marker { content: "\2612"; } - + @@ -444,9 +444,9 @@ li.checked::marker { content: "\2612"; } - + @@ -588,9 +588,9 @@ li.checked::marker { content: "\2612"; } - + @@ -604,9 +604,9 @@ li.checked::marker { content: "\2612"; } - + @@ -620,9 +620,9 @@ li.checked::marker { content: "\2612"; } - + @@ -636,9 +636,9 @@ li.checked::marker { content: "\2612"; } - + @@ -652,9 +652,9 @@ li.checked::marker { content: "\2612"; } - + @@ -668,9 +668,9 @@ li.checked::marker { content: "\2612"; } - + @@ -812,9 +812,9 @@ li.checked::marker { content: "\2612"; } - + @@ -828,9 +828,9 @@ li.checked::marker { content: "\2612"; } - + @@ -844,9 +844,9 @@ li.checked::marker { content: "\2612"; } - + @@ -860,9 +860,9 @@ li.checked::marker { content: "\2612"; } - + @@ -876,9 +876,9 @@ li.checked::marker { content: "\2612"; } - + @@ -892,9 +892,9 @@ li.checked::marker { content: "\2612"; } - + @@ -977,15 +977,15 @@ li.checked::marker { content: "\2612"; } - + - + - + @@ -997,7 +997,7 @@ li.checked::marker { content: "\2612"; } - + @@ -1007,7 +1007,7 @@ li.checked::marker { content: "\2612"; } - + @@ -1075,8 +1075,6 @@ li.checked::marker { content: "\2612"; } - @@ -1117,6 +1115,66 @@ li.checked::marker { content: "\2612"; } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -1129,6 +1187,65 @@ li.checked::marker { content: "\2612"; } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

Text

]]> + +
+
@@ -1209,10 +1326,8 @@ li.checked::marker { content: "\2612"; } - - - + - +
diff --git a/tests/testShadows/test_shadows_mcf-Dateien/folderid.xml b/tests/testShadows/test_shadows_mcf-Dateien/folderid.xml index 0de7f11e..197d309b 100644 --- a/tests/testShadows/test_shadows_mcf-Dateien/folderid.xml +++ b/tests/testShadows/test_shadows_mcf-Dateien/folderid.xml @@ -1,2 +1,2 @@ - + diff --git a/texts/areas.py b/texts/areas.py index 2b65a8b6..e3726637 100644 --- a/texts/areas.py +++ b/texts/areas.py @@ -7,17 +7,23 @@ import logging import re +import copy +import tempfile from math import floor from typing import Any +import pymupdf import reportlab.lib.colors import reportlab.lib.enums import reportlab.lib.pagesizes from reportlab.pdfbase import pdfmetrics +from reportlab.pdfgen import canvas +from reportlab.platypus import Frame +from PIL import Image from lxml import etree from decorations.borders import processDecorationBorders -from decorations.shadows import warnAndIgnoreEnabledDecorationShadow +from decorations.shadows import processDecorationShadow from colorFrame import ColorFrame from colorUtils import ReorderColorBytesMcf2Rl from conversionState import ConversionState @@ -34,6 +40,75 @@ from .spacing import getLetterSpacing +def _hasEnabledShadow(area): + """Return whether an area has a shadow which CEWE has not disabled.""" + return any( + shadow.get('shadowEnabled') != '0' + for decoration in area.findall('decoration') + for shadow in decoration.findall('shadow') + ) + + +def _drawTextShadow(pdf_flowableList, frameWidth, frameHeight, + leftPad, bottomPad, rightPad, + topPad, area, areaHeight, areaWidth, areaRot, pdf, + context: RenderContext, state: ConversionState): + """Render the actual laid-out text to an alpha silhouette and shadow it. + + CEWE shadows the glyphs, not the rectangular text area. Text flowables + already know how to reproduce CEWE's rich text: fonts, spans, tab stops, + outlines and line wrapping. Rendering a copy of them into a transparent + one-page PDF therefore gives us the same alpha silhouette which image and + clip-art shadows use, without attempting to recreate the text in Pillow. + """ + shadowPdf = tempfile.NamedTemporaryFile(suffix='.pdf', delete=False) + shadowPdf.close() + state.temporary_files.append(shadowPdf.name) + + try: + shadowCanvas = canvas.Canvas( + shadowPdf.name, pagesize=(frameWidth, frameHeight) + ) + shadowFrame = ColorFrame( + 0, 0, frameWidth, frameHeight, + leftPadding=leftPad, bottomPadding=bottomPad, + rightPadding=rightPad, topPadding=topPad, + showBoundary=0 + ) + # ``Frame.addFromList`` consumes its input. The ordinary rendering + # below must receive the original flowables, so render a deep copy. + Frame.addFromList(shadowFrame, copy.deepcopy(pdf_flowableList), shadowCanvas) + shadowCanvas.showPage() + shadowCanvas.save() + + shadowDocument = pymupdf.open(shadowPdf.name) + try: + # Use the configured image DPI so shadow blur has the same scale + # as the alpha-silhouette shadows of photos and clip art. + renderScale = context.image_resolution / 72.0 + shadowPixmap = shadowDocument[0].get_pixmap( + matrix=pymupdf.Matrix(renderScale, renderScale), alpha=True + ) + textImage = Image.frombytes( + 'RGBA', (shadowPixmap.width, shadowPixmap.height), + shadowPixmap.samples + ) + finally: + shadowDocument.close() + except Exception: # pylint: disable=broad-exception-caught + logging.exception('Could not create text shadow silhouette') + return + + # The mask occupies the full text area, so it uses the same local origin + # as the real text. The caller has already applied the area's transform. + for decorationTag in area.findall('decoration'): + processDecorationShadow( + decorationTag, areaHeight, areaWidth, pdf, context, state, + im=textImage, imgCropWidth_mcfunit=areaWidth, + imgCropHeight_mcfunit=areaHeight, areaRotation=areaRot + ) + + def processAreaTextTag(textTag, additional_fonts, area, areaWidth, areaHeight, areaRot, pdf, transCx, transCy, # noqa: C901 pgno, context: RenderContext, state: ConversionState, albumIndex: AlbumIndex): mcf2rl = context.mcf_to_reportlab @@ -276,11 +351,9 @@ def merge_duplicate_styles(match): bottomPad = 0.0 indentMargin = 0.0 - # We do not render shadows on text, but we can at least warn about them. # The same decoration stores the opacity used when drawing its background. textAreaAlpha = 1.0 for decorationTag in area.findall('decoration'): - warnAndIgnoreEnabledDecorationShadow(decorationTag, context) alphaText = decorationTag.get('alpha') if alphaText is not None: try: @@ -453,11 +526,24 @@ def merge_duplicate_styles(match): alpha=textAreaAlpha ) + # Draw the background before the glyph shadow. ``ColorFrame.addFromList`` + # would do both background and text together, so call its base implementation + # below after retaining this ordering explicitly. + if newFrame.background: + newFrame.drawBackground(pdf) + + if _hasEnabledShadow(area): + _drawTextShadow( + pdf_flowableList, frameWidth, frameHeight, + leftPad, bottomPad, rightPad, topPad, + area, areaHeight, areaWidth, areaRot, pdf, context, state + ) + # This call should produce an exception, if any of the flowables do not fit inside the frame. # But there seems to be a bug, and no exception is triggered. # We took care of this by making the frame so large, that it always can fit the flowables. # maybe should switch to res=newFrame.split(flowable, pdf) and check the result manually. - newFrame.addFromList(pdf_flowableList, pdf) + Frame.addFromList(newFrame, pdf_flowableList, pdf) for decorationTag in area.findall('decoration'): processDecorationBorders(decorationTag, areaHeight, areaWidth, pdf, context)