diff --git a/README.md b/README.md index f668eeb..c6bbb54 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,10 @@ Example for linux font file and directory paths: ``` #### Missing-font substitutions +Family names are resolved to their loaded faces before missing-font substitution. +If a family has no regular face, an available italic, bold, or bold italic face +is used, in that order. No manual substitution is needed for such a family. + To choose a replacement when a requested font is unavailable, add name pairs to `cewe2pdf.ini`: @@ -323,9 +327,9 @@ the MCF also supplies a positive width and a non-transparent colour. #### Letter spacing -The area-wide `textFormat.letterSpacing` setting is supported. Per-span letter -spacing and wrapping-aware measurement are not implemented, so heavily spaced -text near the edge of a narrow frame deserves visual checking. +The area-wide `textFormat.letterSpacing` setting is supported, including line +breaking and horizontal alignment. Positive spacing can move words to the next +line; negative spacing can let more text fit. Per-span letter spacing is not implemented. #### Tabs diff --git a/fonts/manager.py b/fonts/manager.py index 879b2ed..5e7c17c 100644 --- a/fonts/manager.py +++ b/fonts/manager.py @@ -2,6 +2,7 @@ import os from fontTools import ttLib +from reportlab.lib.fonts import tt2ps from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont @@ -397,11 +398,21 @@ def noteFontSubstitution(family, replacement, state: ConversionState): def getAvailableFont(family, pdf, additional_fonts, state: ConversionState): reportlabFonts = pdf.getAvailableFonts() - if family in reportlabFonts: - bodyfont = family - elif family in additional_fonts: - bodyfont = family - else: - bodyfont = getMissingFontSubstitute(family, state) - noteFontSubstitution(family, bodyfont, state) + if family in reportlabFonts or family in additional_fonts: + return family + + # CEWE names families, whereas the available-font lists contain faces. + # A family with only an italic/bold face still has a usable normal mapping + # from registerFontFamilies. Resolve it before declaring the font missing. + try: + normalFace = tt2ps(family, 0, 0) + except ValueError: + normalFace = None + # ReportLab keeps family mappings across conversions; accept this alias + # only when its face was loaded for the current album. + if normalFace in additional_fonts: + return normalFace + + bodyfont = getMissingFontSubstitute(family, state) + noteFontSubstitution(family, bodyfont, state) return bodyfont diff --git a/tests/testFontSubstitution/test_fontFamilyResolution.py b/tests/testFontSubstitution/test_fontFamilyResolution.py new file mode 100644 index 0000000..e75632f --- /dev/null +++ b/tests/testFontSubstitution/test_fontFamilyResolution.py @@ -0,0 +1,69 @@ +"""Resolve CEWE family names whose only installed face is not regular.""" + +from io import BytesIO +from pathlib import Path +import sys + +import pytest +from reportlab.lib import fonts +from reportlab.lib.styles import ParagraphStyle +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen.canvas import Canvas +from reportlab.platypus import Paragraph + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +from testutils import configureTestImportPaths +configureTestImportPaths(__file__) + +from conversionState import ConversionState +from fonts.manager import getAvailableFont, registerFontFamilies + + +@pytest.fixture +def isolatedFontRegistry(monkeypatch): + # ReportLab's mappings are process-wide. Test registrations must not + # affect later album conversions or depend on earlier test registrations. + for module, name in ((pdfmetrics, '_fonts'), (pdfmetrics, '_dynFaceNames'), + (fonts, '_tt2ps_map'), (fonts, '_ps2tt_map')): + monkeypatch.setattr(module, name, getattr(module, name).copy()) + + +@pytest.mark.parametrize('onlyStyle', ['italic', 'bold', 'boldItalic']) +def test_familyWithNoRegularFaceUsesChosenAlternate(isolatedFontRegistry, onlyStyle): + family = f'Fallback test family {onlyStyle}' + face = f'{family} face' + fontFile = PROJECT_ROOT / 'tests/Resources/photofun/fonts/LiberationSansItalic-RJre.ttf' + pdfmetrics.registerFont(TTFont(face, str(fontFile))) + members = dict.fromkeys(('normal', 'bold', 'italic', 'boldItalic')) + members[onlyStyle] = face + registerFontFamilies({family: members}, []) + pdf = Canvas(BytesIO()) + state = ConversionState() + # Even an explicit missing-font rule must not override an available family. + state.missing_font_substitutions[family] = 'Courier' + + selectedFace = getAvailableFont(family, pdf, {face: str(fontFile)}, state) + + assert selectedFace == face + assert state.noted_font_substitutions == set() + paragraph = Paragraph('Normal bold italic both', + ParagraphStyle('family fallback', fontName=selectedFace)) + assert all(fragment.fontName == face for fragment in paragraph.frags) + paragraph.wrap(300, 100) + paragraph.drawOn(pdf, 10, 10) + pdf.save() + + +def test_familyMappingFromEarlierConversionIsNotEnough(isolatedFontRegistry): + family = 'Unavailable family from earlier conversion' + pdfmetrics.registerFontFamily(family, normal='Courier') + state = ConversionState() + + assert getAvailableFont(family, Canvas(BytesIO()), {}, state) == 'Helvetica' + assert state.noted_font_substitutions == {f'{family}/Helvetica'} + + +if __name__ == '__main__': + sys.exit(pytest.main([__file__])) diff --git a/tests/testLetterSpacing/test_spacingLayout.py b/tests/testLetterSpacing/test_spacingLayout.py new file mode 100644 index 0000000..8085749 --- /dev/null +++ b/tests/testLetterSpacing/test_spacingLayout.py @@ -0,0 +1,90 @@ +"""Measure line breaks and alignment with PDF character spacing enabled.""" + +from io import BytesIO +from pathlib import Path +import sys + +import pytest +from reportlab.lib.styles import ParagraphStyle +from reportlab.lib.enums import TA_CENTER +from reportlab.pdfbase.pdfmetrics import stringWidth +from reportlab.pdfgen.canvas import Canvas + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +from testutils import configureTestImportPaths +configureTestImportPaths(__file__) + +from texts.outlines import TextEffectsParagraph + + +def makeParagraph(text, spacing): + style = ParagraphStyle('spacing test', fontName='Helvetica', fontSize=36, leading=42, + alignment=TA_CENTER, splitLongWords=True) + style.letterSpacing = spacing + return TextEffectsParagraph(text, style) + + +def lineTexts(paragraph): + return [''.join(fragment.text for fragment in line.words) for line in paragraph.blPara.lines] + + +def test_spacedTextWrapsAndCentresUsingVisibleWidth(): + paragraph = makeParagraph('Text with shadow', 10) + paragraph.wrap(330, 500) + assert lineTexts(paragraph) == ['Text with', 'shadow'] + for line in paragraph.blPara.lines: + text = ''.join(fragment.text for fragment in line.words) + visibleWidth = stringWidth(text, 'Helvetica', 36) + (len(text) - 1) * 10 + assert line.extraSpace == pytest.approx(330 - visibleWidth) + assert paragraph.height == 84 + paragraph.wrap(330, 500) + assert lineTexts(paragraph) == ['Text with', 'shadow'] + paragraph.wrap(700, 500) + assert lineTexts(paragraph) == ['Text with shadow'] + + +def test_negativeSpacingAllowsMoreTextOnTheLine(): + paragraph = makeParagraph('Text with shadow', -1) + width = stringWidth('Text with shadow', 'Helvetica', 36) - 5 + paragraph.wrap(width, 500) + assert lineTexts(paragraph) == ['Text with shadow'] + + +def test_inlineStylesAndExplicitBreaksArePreserved(): + paragraph = makeParagraph('Text with shadow
Next line', 10) + paragraph.wrap(330, 500) + assert lineTexts(paragraph) == ['Text with', 'shadow', 'Next line'] + assert [fragment.fontName for fragment in paragraph.blPara.lines[0].words if fragment.text.strip()] == [ + 'Helvetica', 'Helvetica-Bold'] + assert paragraph.blPara.lines[1].lineBreak + pdf = Canvas(BytesIO()) + paragraph.drawOn(pdf, 20, 20) + pdf.save() + + +def test_overlongWordSplitsWithoutLosingStyleOrCharacters(): + paragraph = makeParagraph('abcdefghij', 10) + paragraph.wrap(100, 500) + assert ''.join(lineTexts(paragraph)) == 'abcdefghij' + assert len(paragraph.blPara.lines) > 1 + assert all(line.extraSpace >= 0 for line in paragraph.blPara.lines) + assert paragraph.blPara.lines[-1].words[-1].fontName == 'Helvetica-Bold' + + +def test_existingHyphenRemainsAnAvailableBreak(): + paragraph = makeParagraph('Text-with-shadow', 10) + paragraph.style.embeddedHyphenation = 1 + paragraph.wrap(330, 500) + assert lineTexts(paragraph) == ['Text-with-', 'shadow'] + + +def test_zeroWidthSpaceAdvanceStillPreservesTheSpace(): + spacing = -stringWidth(' ', 'Helvetica', 36) + paragraph = makeParagraph('abc def', spacing) + paragraph.wrap(500, 500) + assert lineTexts(paragraph) == ['abc def'] + + +if __name__ == '__main__': + sys.exit(pytest.main([__file__])) diff --git a/tests/testTextBox/cewe2pdf.ini b/tests/testTextBox/cewe2pdf.ini index d603639..a67a233 100644 --- a/tests/testTextBox/cewe2pdf.ini +++ b/tests/testTextBox/cewe2pdf.ini @@ -1,5 +1,7 @@ [DEFAULT] -#cewe_folder = C:\Program Files\Elkjop fotoservice_6.3\elkjop fotoservice +#cewe_folder = C:\Program Files\Elkjop fotoservice\elkjop fotoservice +#noShadows = False +#loadSystemFonts = True cewe_folder = tests hpsFolder = tests/hps diff --git a/tests/testTextBox/previous_result_pdfs/testTextBox.mcf.20261020S.pdf b/tests/testTextBox/previous_result_pdfs/testTextBox.mcf.20261020S.pdf new file mode 100644 index 0000000..0db7c41 Binary files /dev/null and b/tests/testTextBox/previous_result_pdfs/testTextBox.mcf.20261020S.pdf differ diff --git a/tests/testTextBox/testTextBox.mcf b/tests/testTextBox/testTextBox.mcf index f5f2a21..fc06e19 100644 --- a/tests/testTextBox/testTextBox.mcf +++ b/tests/testTextBox/testTextBox.mcf @@ -1,7 +1,7 @@ - - - + + + @@ -9,9 +9,10 @@ + - - + + @@ -21,8 +22,8 @@ -

Front title: Testing text boxes
Including: Horizontal and vertical alignment and margins

]]> - +

Front title: Testing text boxes
Including: Horizontal and vertical alignment and margins

]]> +
@@ -58,8 +59,8 @@ -

This is left and top aligned text with a 0.0 mm edge margin set

]]> - +

This is left and top aligned text with a 0.0 mm edge margin set

]]> +
@@ -67,8 +68,8 @@ -

This is left and top aligned text with a 2.1 mm edge margin set

]]> - +

This is left and top aligned text with a 2.1 mm edge margin set

]]> +
@@ -76,8 +77,8 @@ -

This is centred and centred text with a 0.0 mm edge margin set

]]> - +

This is centred and centred text with a 0.0 mm edge margin set

]]> +
@@ -85,8 +86,8 @@ -

This is centred and centred text with a 2.1 mm edge margin set

]]> - +

This is centred and centred text with a 2.1 mm edge margin set

]]> +
@@ -94,8 +95,8 @@ -

This is right and bottom aligned text with a 0.0 mm edge margin set

]]> - +

This is right and bottom aligned text with a 0.0 mm edge margin set

]]> +
@@ -103,8 +104,8 @@ -

This is right and bottom aligned text with a 2.1 mm edge margin set

]]> - +

This is right and bottom aligned text with a 2.1 mm edge margin set

]]> +
@@ -112,8 +113,8 @@ -

Single line left, top, 0.0

]]> - +

Single line left, top, 0.0

]]> +
@@ -121,8 +122,8 @@ -

Single line left, top, 2.1

]]> - +

Single line left, top, 2.1

]]> +
@@ -130,8 +131,8 @@ -

Single line ctr, ctr, 0.0

]]> - +

Single line ctr, ctr, 0.0

]]> +
@@ -139,8 +140,8 @@ -

Single line ctr, ctr, 0.0

]]> - +

Single line ctr, ctr, 0.0

]]> +
@@ -148,8 +149,8 @@ -

Single, right, bottom, 0.0

]]> - +

Single, right, bottom, 0.0

]]> +
@@ -157,8 +158,35 @@ -

Single, right, bottom, 2.1

]]> - +

Single, right, bottom, 2.1

]]> + +
+ + + + + + +

Text with shadow

]]> + +
+ + + + + + +

Text with shadow

]]> + +
+ + + + + + +

The boxes above check that letter spacing is taken into account in calculating line breaks. The second one happens to check font substitution for Brush Italic MT which only has an italic entry in the family, but you can only see that when executing locally with a full font set, and not on the github tests

]]> +
@@ -313,5 +341,5 @@ - +
diff --git a/tests/testTextBox/testTextBox_mcf-Dateien/folderid.xml b/tests/testTextBox/testTextBox_mcf-Dateien/folderid.xml index 254eeb3..ffbe290 100644 --- a/tests/testTextBox/testTextBox_mcf-Dateien/folderid.xml +++ b/tests/testTextBox/testTextBox_mcf-Dateien/folderid.xml @@ -1,2 +1,2 @@ - + diff --git a/texts/outlines.py b/texts/outlines.py index 01af71f..098f36b 100644 --- a/texts/outlines.py +++ b/texts/outlines.py @@ -6,6 +6,7 @@ from reportlab.platypus import Paragraph from colorUtils import ReorderColorBytesMcf2Rl +from texts.spacing import breakSpacedLines @dataclass(frozen=True) @@ -65,6 +66,16 @@ def getTextOutline(text_tag, mcf_to_reportlab): class TextEffectsParagraph(Paragraph): """A ReportLab paragraph which applies CEWE text effects when requested.""" + def breakLines(self, width): + letter_spacing = getattr(self.style, 'letterSpacing', 0.0) + if letter_spacing: + lines = breakSpacedLines(self, width, letter_spacing) + # ReportLab uses this measured width when sizing/splitting the + # finished paragraph, alongside the normal FragLine structures. + self._width_max = max((line.maxWidth - line.extraSpace for line in lines.lines), default=0) # pylint: disable=attribute-defined-outside-init + return lines + return super().breakLines(width) + def beginText(self, x, y): text_object = super().beginText(x, y) letter_spacing = getattr(self.style, 'letterSpacing', 0.0) diff --git a/texts/spacing.py b/texts/spacing.py index 674a713..25f16b7 100644 --- a/texts/spacing.py +++ b/texts/spacing.py @@ -1,6 +1,11 @@ """CEWE letter-spacing parsing.""" import logging +from collections import deque + +from reportlab.pdfbase.pdfmetrics import getAscentDescent, stringWidth +from reportlab.platypus.paragraph import ( + FragLine, ParaLines, _getFragWords, _HSFrag, sameFrag) def getLetterSpacing(text_format, mcf_to_reportlab): @@ -12,8 +17,7 @@ def getLetterSpacing(text_format, mcf_to_reportlab): avoids an otherwise undocumented pixel-to-point conversion. This initial implementation deliberately applies one value to the whole - text area. Per-span character spacing and wrapping-aware measurement are - separate enhancements. + text area. Per-span character spacing is not implemented. """ if text_format is None: return 0.0 @@ -27,3 +31,118 @@ def getLetterSpacing(text_format, mcf_to_reportlab): except ValueError: logging.warning(f"Ignoring invalid text letterSpacing setting {spacing_text!r}") return 0.0 + + +def _textAdvance(fragment, spacing): + """Include the advance after the last character, as PDF text does.""" + return (stringWidth(fragment.text, fragment.fontName, fragment.fontSize) + + len(fragment.text) * spacing) + + +def _splitSpacedWord(fragments, width, spacing): + """Split an overlong word without losing its inline font/style changes.""" + head = [] + tail = [] + advance = 0.0 + full = False + for fragment in fragments: + prefix = '' + for index, character in enumerate(fragment.text): + characterAdvance = stringWidth(character, fragment.fontName, fragment.fontSize) + spacing + if full or ((head or prefix) and advance + characterAdvance - spacing > width): + tail.append(fragment.clone(text=fragment.text[index:])) + full = True + break + prefix += character + advance += characterAdvance + if prefix: + head.append(fragment.clone(text=prefix)) + return head, tail + + +def _getSpacedWords(fragments, embeddedHyphenation): + words = deque() + # _getFragWords and _HSFrag are ReportLab's word/style representation: + # _HSFrag marks a word followed by a collapsible space, not a visible glyph. + for word in _getFragWords(fragments): + fragments = [fragment.clone(text=text) for fragment, text in word[1:]] + portions = [[]] + for fragment in fragments: + # Preserve the configured opportunity to break after existing + # hyphens, including hyphenated words spanning inline styles. + parts = fragment.text.split('-') if embeddedHyphenation else [fragment.text] + for index, part in enumerate(parts): + hasHyphen = index < len(parts) - 1 + portions[-1].append(fragment.clone(text=part + ('-' if hasHyphen else ''))) + if hasHyphen: + portions.append([]) + for index, portion in enumerate(portions): + trailingSpace = index == len(portions) - 1 and isinstance(word, _HSFrag) + words.append((portion, trailingSpace)) + + return words + + +def breakSpacedLines(paragraph, widths, spacing): + """Lay out spaced words using ReportLab's parsed text/style fragments. + + ReportLab's ordinary breaker measures glyph widths without PDF character + spacing. Keep its fragment parser and drawing/height machinery, but measure + each line's actual advance here. Nothing in ReportLab's shared registries or + module-level measurement functions is changed for this paragraph. + """ + if not isinstance(widths, (tuple, list)): + widths = [widths] + words = _getSpacedWords(paragraph.frags, paragraph.style.embeddedHyphenation) + + lines = [] + line = [] + advance = 0.0 + pendingSpace = None + + def finishLine(explicitBreak=False): + nonlocal line, advance, pendingSpace + width = widths[min(len(lines), len(widths) - 1)] + textWidth = advance - spacing if line else 0 + bounds = [getAscentDescent(fragment.fontName, fragment.fontSize) for fragment in line] + lines.append(FragLine( + extraSpace=width - textWidth, wordCount=1 + sum(fragment.text.count(' ') for fragment in line), + lineBreak=explicitBreak, words=line, + fontSize=max((fragment.fontSize for fragment in line), default=paragraph.style.fontSize), + ascent=max((bound[0] for bound in bounds), default=paragraph.style.fontSize), + descent=min((bound[1] for bound in bounds), default=0), maxWidth=width)) + line = [] + advance = 0.0 + pendingSpace = None + + while words: + fragments, trailingSpace = words.popleft() + if getattr(fragments[0], 'lineBreak', False): + finishLine(explicitBreak=True) + continue + wordAdvance = sum(_textAdvance(fragment, spacing) for fragment in fragments) + gap = _textAdvance(pendingSpace, spacing) if line and pendingSpace else 0 + width = widths[min(len(lines), len(widths) - 1)] + if line and advance + gap + wordAdvance - spacing > width: + finishLine() + width = widths[min(len(lines), len(widths) - 1)] + gap = 0 + if not line and wordAdvance - spacing > width and paragraph.style.splitLongWords: + fragments, remainder = _splitSpacedWord(fragments, width, spacing) + if remainder: + words.appendleft((remainder, trailingSpace)) + trailingSpace = False + wordAdvance = sum(_textAdvance(fragment, spacing) for fragment in fragments) + if line and pendingSpace is not None: + line.append(pendingSpace) + advance += gap + for fragment in fragments: + if line and sameFrag(line[-1], fragment): + line[-1].text += fragment.text + else: + line.append(fragment.clone()) + advance += wordAdvance + pendingSpace = fragments[-1].clone(text=' ') if trailingSpace else None + if line: + finishLine() + return ParaLines(kind=1, lines=lines)