diff --git a/AGENTS.md b/AGENTS.md index 17853711..844ec67d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ Read this file before editing. Use the [README documentation map](README.md#docu - Use one indent level for continuation lines rather than aligning beneath an earlier expression. - Explain undocumented CEWE conventions, empirical calibration, ownership, and non-obvious Python mechanisms in focused comments. - Use descriptive domain names; avoid unrelated formatting, consistency renames, and architectural rewrites of working code. +- Write for contributors who may be new to Python or this project, including coding assistants. Prefer straightforward code and descriptive names over clever or cryptic abstractions. - Follow the repository's flake8/pylint configuration. Use narrow, explained suppressions rather than distorting a design to satisfy a metric. ## Platforms and file integrity diff --git a/requirements-pinned.txt b/requirements-pinned.txt index 7109b3e2..53ec7a4d 100644 --- a/requirements-pinned.txt +++ b/requirements-pinned.txt @@ -56,7 +56,7 @@ packaging==24.2 # pytest pikepdf==10.11.0 # via -r requirements.txt -pillow==12.3.0 +pillow==13.0.0 # via # -r requirements.txt # cairosvg diff --git a/requirements-winexe.txt b/requirements-winexe.txt index 9fe4db14..71ddc8ab 100644 --- a/requirements-winexe.txt +++ b/requirements-winexe.txt @@ -8,4 +8,4 @@ pefile==2024.8.26 pyinstaller==6.22.0 pyinstaller-hooks-contrib==2026.6 pywin32-ctypes==0.2.3 -setuptools==84.0.0 +setuptools==84.1.0 diff --git a/requirements.txt b/requirements.txt index b5a3a6fb..cb694459 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ cairosvg>=2.9.1 fonttools>=4.25.1 lxml>=4.4.1 packaging>=24.0,<25 -pillow>=5.4.1 +pillow>=13.0.0 # Required to render HEIC/HEIF photographs stored directly in an album. pillow-heif>=1.0 # Page.get_images(), used by the clipart test, was added in pikepdf 10.9. diff --git a/tests/testShadows/previous_result_pdfs/test_shadows.mcf.20261024S.pdf b/tests/testShadows/previous_result_pdfs/test_shadows.mcf.20261024S.pdf new file mode 100644 index 00000000..147478e1 Binary files /dev/null and b/tests/testShadows/previous_result_pdfs/test_shadows.mcf.20261024S.pdf differ diff --git a/tests/testTextLinks/test_links.py b/tests/testTextLinks/test_links.py index 44f43005..09b213be 100644 --- a/tests/testTextLinks/test_links.py +++ b/tests/testTextLinks/test_links.py @@ -3,8 +3,10 @@ from pathlib import Path import sys +from lxml import etree import pymupdf from reportlab.lib.styles import getSampleStyleSheet +from reportlab.pdfbase import pdfmetrics from reportlab.pdfgen import canvas from reportlab.platypus import Frame @@ -16,7 +18,7 @@ configureTestImportPaths(__file__) -from texts.links import _urlsInText, addWebLinksToPdf +from texts.links import _urlsInText, addWebLinksToPdf, getAlbumTextUrls from texts.markup import AddAlbumText from texts.outlines import TextEffectsParagraph @@ -60,6 +62,38 @@ def test_wrappedHttpUrlCreatesOneExternalPdfLink(tmp_path): assert {link['uri'] for link in links} == {url} +def test_urlFragmentsAcrossPdfTextRunsBecomeExternalLink(tmp_path): + outputFile = tmp_path / 'split-web-link.pdf' + url = 'https://storysontheroad2023.blogspot.com/' + pdf = canvas.Canvas(str(outputFile)) + font = 'Helvetica' + fontSize = 14 + xPosition = 40 + fragments = ( + ('https:/', fontSize), + (' ', 0.01), + ('/', fontSize), + (' ', 0.01), + ('storysontheroad2023.blogspot.com/', fontSize), + (' ', 0.01), + ) + for text, textSize in fragments: + pdf.setFont(font, textSize) + pdf.drawString(xPosition, 650, text) + xPosition += pdfmetrics.stringWidth(text, font, textSize) + pdf.save() + + addWebLinksToPdf(outputFile, {url}) + + with pymupdf.open(outputFile) as renderedPdf: + extractedText = renderedPdf[0].get_text() + links = renderedPdf[0].get_links() + + assert 'https:/ / storysontheroad2023.blogspot.com/' in extractedText + assert len(links) == 1 + assert links[0]['uri'] == url + + def test_albumTextEscapesUrlAndAddsInvisibleSlashBreaks(): assert AddAlbumText('', 'See https://example.com/a?x=1&y=2, please.') == ( 'See https:/ / ' @@ -73,3 +107,13 @@ def test_albumTextEscapesOrdinaryTextWithoutSubstitution(): def test_urlInParenthesesExcludesTheClosingParenthesis(): assert _urlsInText('(https://www.youtube.com/watch?v=zvB0jZKRKrk)') == { 'https://www.youtube.com/watch?v=zvB0jZKRKrk'} + + +def test_urlAtParagraphEndDoesNotAbsorbNextParagraphStart(): + album = etree.fromstring( + '' + '

Visit https://example.com/

' + '

A second paragraph

' + ']]>
') + + assert getAlbumTextUrls(album) == {'https://example.com/'} diff --git a/tests/testTransparency/previous_result_pdfs/testTransparency.mcf.20261024S.pdf b/tests/testTransparency/previous_result_pdfs/testTransparency.mcf.20261024S.pdf new file mode 100644 index 00000000..e9288ad0 Binary files /dev/null and b/tests/testTransparency/previous_result_pdfs/testTransparency.mcf.20261024S.pdf differ diff --git a/texts/links.py b/texts/links.py index 0f0b0d30..9e13e130 100644 --- a/texts/links.py +++ b/texts/links.py @@ -28,10 +28,24 @@ def getAlbumTextUrls(fotobook): continue body = document.find('body') if body is not None: - urls.update(_urlsInText(''.join(body.itertext()))) + paragraphs = body.xpath('.//p') or [body] + for paragraph in paragraphs: + urls.update(_urlsInText(_textWithBreaks(paragraph))) return urls +def _textWithBreaks(element): + """Join inline text while retaining explicit HTML line breaks.""" + textParts = [element.text or ''] + for child in element: + if child.tag.lower() == 'br': + textParts.append('\n') + else: + textParts.append(_textWithBreaks(child)) + textParts.append(child.tail or '') + return ''.join(textParts) + + def addWebLinksToPdf(pdfFileName, urls): """Overlay external-link rectangles without changing rendered text.""" if not urls: @@ -43,7 +57,7 @@ def addWebLinksToPdf(pdfFileName, urls): for page in renderedPdf: words = page.get_text('words', sort=True) for url in urls: - for rects in _rectanglesForUrl(words, url): + for rects in _findUrlRectangles(words, url): # A PDF link annotation has one rectangular hit area. # Keeping one annotation per wrapped URL makes link lists # in readers such as MJ PDF show duplicate destinations. @@ -89,49 +103,40 @@ def _enclosingRectangle(rectangles): return enclosingRectangle -def _rectanglesForUrl(words, url): - """Yield visible word rectangles which together spell ``url``.""" - # Text from separate areas can share a visual line. ``sort=True`` puts - # those words beside one another, which would interrupt a URL wrapped in - # a single text area. Follow only the words in each PDF text block. - blocks = {} - for word in words: - blocks.setdefault(word[5], []).append(word) - - for blockWords in blocks.values(): - orderedWords = sorted(blockWords, key=lambda word: (word[6], word[7])) - yield from _rectanglesWithinBlock(orderedWords, url) - - -def _rectanglesWithinBlock(words, url): - """Yield URL rectangles from one continuous PDF text block.""" +def _findUrlRectangles(words, url): + """Yield groups of PDF word rectangles spelling visible occurrences of ``url``.""" + # PDF readers can report the tiny slash-break spaces as separate words or + # text blocks. Match the exact URL in the page's reading order across those + # boundaries; any unrelated word interrupts the match. for startIndex, word in enumerate(words): # A styled URL can touch its preceding opening parenthesis. PyMuPDF # then returns one word such as ``(https:/``. Start matching at the # scheme rather than requiring the PDF word to start with it. - schemeStart = word[4].lower().find('http') + wordText = word[4] + schemeStart = wordText.lower().find('http') if schemeStart < 0: continue - wordText = word[4][schemeStart:] - if wordText.startswith(url): + firstFragment = wordText[schemeStart:] + if firstFragment.startswith(url): yield [pymupdf.Rect(*word[:4])] continue - if not url.startswith(wordText): + if not url.startswith(firstFragment): continue - matchedText = wordText - rectangles = [pymupdf.Rect(*word[:4])] + matchedText = firstFragment + fragmentRectangles = [pymupdf.Rect(*word[:4])] for nextWord in words[startIndex + 1:]: - extendedText = matchedText + nextWord[4] - if not url.startswith(extendedText): + nextWordText = nextWord[4] + candidateText = matchedText + nextWordText + if not url.startswith(candidateText): # The last visible fragment can include sentence punctuation. # It remains part of the clickable rectangle, but not the URL. - if _stripTrailingPunctuation(extendedText) == url: - rectangles.append(pymupdf.Rect(*nextWord[:4])) - yield rectangles + if _stripTrailingPunctuation(candidateText) == url: + fragmentRectangles.append(pymupdf.Rect(*nextWord[:4])) + yield fragmentRectangles break - rectangles.append(pymupdf.Rect(*nextWord[:4])) - matchedText = extendedText + fragmentRectangles.append(pymupdf.Rect(*nextWord[:4])) + matchedText = candidateText if matchedText == url: - yield rectangles + yield fragmentRectangles break