Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements-pinned.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion requirements-winexe.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file not shown.
46 changes: 45 additions & 1 deletion tests/testTextLinks/test_links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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:/<font size="0.01"> </font>/<font size="0.01"> </font>'
Expand All @@ -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(
'<album><text><![CDATA[<html><body>'
'<p>Visit https://example.com/</p>'
'<p>A second paragraph</p>'
'</body></html>]]></text></album>')

assert getAlbumTextUrls(album) == {'https://example.com/'}
Binary file not shown.
69 changes: 37 additions & 32 deletions texts/links.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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
Loading