731 lines
24 KiB
Python
731 lines
24 KiB
Python
import datetime
|
|
import decimal
|
|
import io
|
|
from pathlib import Path
|
|
import string
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
import matplotlib as mpl
|
|
from matplotlib import (
|
|
pyplot as plt, rcParams, font_manager as fm
|
|
)
|
|
from matplotlib.cbook import _get_data_path
|
|
from matplotlib.ft2font import FT2Font
|
|
from matplotlib.backends._backend_pdf_ps import get_glyphs_subset, font_as_file
|
|
from matplotlib.backends.backend_pdf import PdfPages
|
|
from matplotlib.patches import Rectangle
|
|
from matplotlib.testing import _gen_multi_font_text, _has_tex_package
|
|
from matplotlib.testing.decorators import check_figures_equal, image_comparison
|
|
from matplotlib.testing._markers import needs_usetex
|
|
|
|
|
|
@image_comparison(['pdf_use14corefonts.pdf'], style='_classic_test')
|
|
def test_use14corefonts():
|
|
rcParams['pdf.use14corefonts'] = True
|
|
rcParams['font.family'] = 'sans-serif'
|
|
rcParams['font.size'] = 8
|
|
rcParams['font.sans-serif'] = ['Helvetica']
|
|
rcParams['pdf.compression'] = 0
|
|
|
|
text = '''A three-line text positioned just above a blue line
|
|
and containing some French characters and the euro symbol:
|
|
"Merci pépé pour les 10 €"'''
|
|
|
|
fig, ax = plt.subplots()
|
|
ax.set_title('Test PDF backend with option use14corefonts=True')
|
|
ax.text(0.5, 0.5, text, horizontalalignment='center',
|
|
verticalalignment='bottom',
|
|
fontsize=14)
|
|
ax.axhline(0.5, linewidth=0.5)
|
|
|
|
|
|
def test_multipage_pagecount():
|
|
with PdfPages(io.BytesIO()) as pdf:
|
|
assert pdf.get_pagecount() == 0
|
|
fig, ax = plt.subplots()
|
|
ax.plot([1, 2, 3])
|
|
fig.savefig(pdf, format="pdf")
|
|
assert pdf.get_pagecount() == 1
|
|
pdf.savefig()
|
|
assert pdf.get_pagecount() == 2
|
|
|
|
|
|
def test_multipage_properfinalize():
|
|
pdfio = io.BytesIO()
|
|
with PdfPages(pdfio) as pdf:
|
|
for i in range(10):
|
|
fig, ax = plt.subplots()
|
|
ax.set_title('This is a long title')
|
|
fig.savefig(pdf, format="pdf")
|
|
s = pdfio.getvalue()
|
|
assert s.count(b'startxref') == 1
|
|
assert len(s) < 40000
|
|
|
|
|
|
def test_multipage_keep_empty(tmp_path):
|
|
# An empty pdf deletes itself afterwards.
|
|
fn = tmp_path / "a.pdf"
|
|
with PdfPages(fn) as pdf:
|
|
pass
|
|
assert not fn.exists()
|
|
|
|
# Test pdf files with content, they should never be deleted.
|
|
fn = tmp_path / "b.pdf"
|
|
with PdfPages(fn) as pdf:
|
|
pdf.savefig(plt.figure())
|
|
assert fn.exists()
|
|
|
|
|
|
def test_composite_image():
|
|
# Test that figures can be saved with and without combining multiple images
|
|
# (on a single set of axes) into a single composite image.
|
|
X, Y = np.meshgrid(np.arange(-5, 5, 1), np.arange(-5, 5, 1))
|
|
Z = np.sin(Y ** 2)
|
|
fig, ax = plt.subplots()
|
|
ax.set_xlim(0, 3)
|
|
ax.imshow(Z, extent=[0, 1, 0, 1])
|
|
ax.imshow(Z[::-1], extent=[2, 3, 0, 1])
|
|
plt.rcParams['image.composite_image'] = True
|
|
with PdfPages(io.BytesIO()) as pdf:
|
|
fig.savefig(pdf, format="pdf")
|
|
assert len(pdf._file._images) == 1
|
|
plt.rcParams['image.composite_image'] = False
|
|
with PdfPages(io.BytesIO()) as pdf:
|
|
fig.savefig(pdf, format="pdf")
|
|
assert len(pdf._file._images) == 2
|
|
|
|
|
|
def test_indexed_image():
|
|
# An image with low color count should compress to a palette-indexed format.
|
|
pikepdf = pytest.importorskip('pikepdf')
|
|
|
|
data = np.zeros((256, 1, 3), dtype=np.uint8)
|
|
data[:, 0, 0] = np.arange(256) # Maximum unique colours for an indexed image.
|
|
|
|
rcParams['pdf.compression'] = True
|
|
fig = plt.figure()
|
|
fig.figimage(data, resize=True)
|
|
buf = io.BytesIO()
|
|
fig.savefig(buf, format='pdf', dpi='figure')
|
|
|
|
with pikepdf.Pdf.open(buf) as pdf:
|
|
page, = pdf.pages
|
|
image, = page.images.values()
|
|
pdf_image = pikepdf.PdfImage(image)
|
|
assert pdf_image.indexed
|
|
pil_image = pdf_image.as_pil_image()
|
|
rgb = np.asarray(pil_image.convert('RGB'))
|
|
|
|
np.testing.assert_array_equal(data, rgb)
|
|
|
|
|
|
def test_savefig_metadata(monkeypatch):
|
|
pikepdf = pytest.importorskip('pikepdf')
|
|
monkeypatch.setenv('SOURCE_DATE_EPOCH', '0')
|
|
|
|
fig, ax = plt.subplots()
|
|
ax.plot(range(5))
|
|
|
|
md = {
|
|
'Author': 'me',
|
|
'Title': 'Multipage PDF',
|
|
'Subject': 'Test page',
|
|
'Keywords': 'test,pdf,multipage',
|
|
'ModDate': datetime.datetime(
|
|
1968, 8, 1, tzinfo=datetime.timezone(datetime.timedelta(0))),
|
|
'Trapped': 'True'
|
|
}
|
|
buf = io.BytesIO()
|
|
fig.savefig(buf, metadata=md, format='pdf')
|
|
|
|
with pikepdf.Pdf.open(buf) as pdf:
|
|
info = {k: str(v) for k, v in pdf.docinfo.items()}
|
|
|
|
assert info == {
|
|
'/Author': 'me',
|
|
'/CreationDate': 'D:19700101000000Z',
|
|
'/Creator': f'Matplotlib v{mpl.__version__}, https://matplotlib.org',
|
|
'/Keywords': 'test,pdf,multipage',
|
|
'/ModDate': 'D:19680801000000Z',
|
|
'/Producer': f'Matplotlib pdf backend v{mpl.__version__}',
|
|
'/Subject': 'Test page',
|
|
'/Title': 'Multipage PDF',
|
|
'/Trapped': '/True',
|
|
}
|
|
|
|
|
|
def test_invalid_metadata():
|
|
fig, ax = plt.subplots()
|
|
|
|
with pytest.warns(UserWarning,
|
|
match="Unknown infodict keyword: 'foobar'."):
|
|
fig.savefig(io.BytesIO(), format='pdf', metadata={'foobar': 'invalid'})
|
|
|
|
with pytest.warns(UserWarning,
|
|
match='not an instance of datetime.datetime.'):
|
|
fig.savefig(io.BytesIO(), format='pdf',
|
|
metadata={'ModDate': '1968-08-01'})
|
|
|
|
with pytest.warns(UserWarning,
|
|
match='not one of {"True", "False", "Unknown"}'):
|
|
fig.savefig(io.BytesIO(), format='pdf', metadata={'Trapped': 'foo'})
|
|
|
|
with pytest.warns(UserWarning, match='not an instance of str.'):
|
|
fig.savefig(io.BytesIO(), format='pdf', metadata={'Title': 1234})
|
|
|
|
|
|
def test_multipage_metadata(monkeypatch):
|
|
pikepdf = pytest.importorskip('pikepdf')
|
|
monkeypatch.setenv('SOURCE_DATE_EPOCH', '0')
|
|
|
|
fig, ax = plt.subplots()
|
|
ax.plot(range(5))
|
|
|
|
md = {
|
|
'Author': 'me',
|
|
'Title': 'Multipage PDF',
|
|
'Subject': 'Test page',
|
|
'Keywords': 'test,pdf,multipage',
|
|
'ModDate': datetime.datetime(
|
|
1968, 8, 1, tzinfo=datetime.timezone(datetime.timedelta(0))),
|
|
'Trapped': 'True'
|
|
}
|
|
buf = io.BytesIO()
|
|
with PdfPages(buf, metadata=md) as pdf:
|
|
pdf.savefig(fig)
|
|
pdf.savefig(fig)
|
|
|
|
with pikepdf.Pdf.open(buf) as pdf:
|
|
info = {k: str(v) for k, v in pdf.docinfo.items()}
|
|
|
|
assert info == {
|
|
'/Author': 'me',
|
|
'/CreationDate': 'D:19700101000000Z',
|
|
'/Creator': f'Matplotlib v{mpl.__version__}, https://matplotlib.org',
|
|
'/Keywords': 'test,pdf,multipage',
|
|
'/ModDate': 'D:19680801000000Z',
|
|
'/Producer': f'Matplotlib pdf backend v{mpl.__version__}',
|
|
'/Subject': 'Test page',
|
|
'/Title': 'Multipage PDF',
|
|
'/Trapped': '/True',
|
|
}
|
|
|
|
|
|
def test_text_urls():
|
|
pikepdf = pytest.importorskip('pikepdf')
|
|
|
|
test_url = 'https://test_text_urls.matplotlib.org/'
|
|
|
|
fig = plt.figure(figsize=(2, 1))
|
|
fig.text(0.1, 0.1, 'test plain 123', url=f'{test_url}plain')
|
|
fig.text(0.1, 0.4, 'test mathtext $123$', url=f'{test_url}mathtext')
|
|
|
|
with io.BytesIO() as fd:
|
|
fig.savefig(fd, format='pdf')
|
|
|
|
with pikepdf.Pdf.open(fd) as pdf:
|
|
annots = pdf.pages[0].Annots
|
|
|
|
# Iteration over Annots must occur within the context manager,
|
|
# otherwise it may fail depending on the pdf structure.
|
|
for y, fragment in [('0.1', 'plain'), ('0.4', 'mathtext')]:
|
|
annot = next(
|
|
(a for a in annots if a.A.URI == f'{test_url}{fragment}'),
|
|
None)
|
|
assert annot is not None
|
|
assert getattr(annot, 'QuadPoints', None) is None
|
|
# Positions in points (72 per inch.)
|
|
assert annot.Rect[1] == decimal.Decimal(y) * 72
|
|
|
|
|
|
def test_text_rotated_urls():
|
|
pikepdf = pytest.importorskip('pikepdf')
|
|
|
|
test_url = 'https://test_text_urls.matplotlib.org/'
|
|
|
|
fig = plt.figure(figsize=(1, 1))
|
|
fig.text(0.1, 0.1, 'N', rotation=45, url=f'{test_url}')
|
|
|
|
with io.BytesIO() as fd:
|
|
fig.savefig(fd, format='pdf')
|
|
|
|
with pikepdf.Pdf.open(fd) as pdf:
|
|
annots = pdf.pages[0].Annots
|
|
|
|
# Iteration over Annots must occur within the context manager,
|
|
# otherwise it may fail depending on the pdf structure.
|
|
annot = next(
|
|
(a for a in annots if a.A.URI == f'{test_url}'),
|
|
None)
|
|
assert annot is not None
|
|
assert getattr(annot, 'QuadPoints', None) is not None
|
|
# Positions in points (72 per inch)
|
|
assert annot.Rect[0] == \
|
|
annot.QuadPoints[6] - decimal.Decimal('0.00001')
|
|
|
|
|
|
@needs_usetex
|
|
def test_text_urls_tex():
|
|
pikepdf = pytest.importorskip('pikepdf')
|
|
|
|
test_url = 'https://test_text_urls.matplotlib.org/'
|
|
|
|
fig = plt.figure(figsize=(2, 1))
|
|
fig.text(0.1, 0.7, 'test tex $123$', usetex=True, url=f'{test_url}tex')
|
|
|
|
with io.BytesIO() as fd:
|
|
fig.savefig(fd, format='pdf')
|
|
|
|
with pikepdf.Pdf.open(fd) as pdf:
|
|
annots = pdf.pages[0].Annots
|
|
|
|
# Iteration over Annots must occur within the context manager,
|
|
# otherwise it may fail depending on the pdf structure.
|
|
annot = next(
|
|
(a for a in annots if a.A.URI == f'{test_url}tex'),
|
|
None)
|
|
assert annot is not None
|
|
# Positions in points (72 per inch.)
|
|
assert annot.Rect[1] == decimal.Decimal('0.7') * 72
|
|
|
|
|
|
def test_pdfpages_fspath(tmp_path):
|
|
with PdfPages(tmp_path / 'unused.pdf') as pdf:
|
|
pdf.savefig(plt.figure())
|
|
|
|
|
|
@image_comparison(['hatching_legend.pdf'], style='mpl20')
|
|
def test_hatching_legend(text_placeholders):
|
|
"""Test for correct hatching on patches in legend"""
|
|
fig = plt.figure(figsize=(1, 2))
|
|
|
|
a = Rectangle([0, 0], 0, 0, facecolor="green", hatch="XXXX")
|
|
b = Rectangle([0, 0], 0, 0, facecolor="blue", hatch="XXXX")
|
|
|
|
# Verify that hatches in PDFs work after empty labels. See
|
|
# https://github.com/matplotlib/matplotlib/issues/4469
|
|
fig.legend([a, b, a, b], ["", "", "", ""])
|
|
|
|
|
|
@image_comparison(['grayscale_alpha.pdf'], style='_classic_test')
|
|
def test_grayscale_alpha():
|
|
"""Masking images with NaN did not work for grayscale images"""
|
|
x, y = np.ogrid[-2:2:.1, -2:2:.1]
|
|
dd = np.exp(-(x**2 + y**2))
|
|
dd[dd < .1] = np.nan
|
|
fig, ax = plt.subplots()
|
|
ax.imshow(dd, interpolation='none', cmap='gray_r')
|
|
ax.set_xticks([])
|
|
ax.set_yticks([])
|
|
|
|
|
|
@mpl.style.context('default')
|
|
@check_figures_equal(extensions=["pdf", "eps"])
|
|
def test_pdf_eps_savefig_when_color_is_none(fig_test, fig_ref):
|
|
ax_test = fig_test.add_subplot()
|
|
ax_test.set_axis_off()
|
|
ax_test.plot(np.sin(np.linspace(-5, 5, 100)), "v", c="none")
|
|
ax_ref = fig_ref.add_subplot()
|
|
ax_ref.set_axis_off()
|
|
|
|
|
|
@needs_usetex
|
|
def test_failing_latex():
|
|
"""Test failing latex subprocess call"""
|
|
plt.xlabel("$22_2_2$", usetex=True) # This fails with "Double subscript"
|
|
with pytest.raises(RuntimeError):
|
|
plt.savefig(io.BytesIO(), format="pdf")
|
|
|
|
|
|
def test_empty_rasterized():
|
|
# Check that empty figures that are rasterised save to pdf files fine
|
|
fig, ax = plt.subplots()
|
|
ax.plot([], [], rasterized=True)
|
|
fig.savefig(io.BytesIO(), format="pdf")
|
|
|
|
|
|
@image_comparison(['kerning.pdf'], style='mpl20')
|
|
def test_kerning():
|
|
fig = plt.figure()
|
|
s = "AVAVAVAVAVAVAVAV€AAVV"
|
|
fig.text(0, .25, s, size=5)
|
|
fig.text(0, .75, s, size=20)
|
|
|
|
|
|
def test_glyphs_subset():
|
|
fpath = str(_get_data_path("fonts/ttf/DejaVuSerif.ttf"))
|
|
chars = "these should be subsetted! 1234567890"
|
|
|
|
# non-subsetted FT2Font
|
|
nosubfont = FT2Font(fpath)
|
|
nosubfont.set_text(chars)
|
|
nosubcmap = nosubfont.get_charmap()
|
|
|
|
# subsetted FT2Font
|
|
glyph_indices = {nosubcmap[ord(c)] for c in chars}
|
|
with get_glyphs_subset(fm.FontPath(fpath, 0), glyph_indices) as subset:
|
|
subfont = FT2Font(font_as_file(subset))
|
|
subfont.set_text(chars)
|
|
subcmap = subfont.get_charmap()
|
|
|
|
# all unique chars must be available in subsetted font
|
|
assert {*chars} == {chr(key) for key in subcmap}
|
|
|
|
# subsetted font's charmap should have less entries
|
|
assert len(subcmap) < len(nosubcmap)
|
|
|
|
# since both objects are assigned same characters
|
|
assert subfont.get_num_glyphs() == nosubfont.get_num_glyphs()
|
|
|
|
|
|
@image_comparison(["multi_font_type3.pdf"], style='mpl20')
|
|
def test_multi_font_type3():
|
|
fonts, test_str = _gen_multi_font_text()
|
|
plt.rc('font', family=fonts, size=16)
|
|
plt.rc('pdf', fonttype=3)
|
|
|
|
fig = plt.figure(figsize=(8, 6))
|
|
fig.text(0.5, 0.5, test_str,
|
|
horizontalalignment='center', verticalalignment='center')
|
|
|
|
|
|
@image_comparison(["multi_font_type42.pdf"], style='mpl20')
|
|
def test_multi_font_type42():
|
|
fonts, test_str = _gen_multi_font_text()
|
|
plt.rc('font', family=fonts, size=16)
|
|
plt.rc('pdf', fonttype=42)
|
|
|
|
fig = plt.figure(figsize=(8, 6))
|
|
fig.text(0.5, 0.5, test_str,
|
|
horizontalalignment='center', verticalalignment='center')
|
|
|
|
|
|
@image_comparison(['ttc_type3.pdf'], style='mpl20')
|
|
def test_ttc_type3():
|
|
fp = fm.FontProperties(family=['WenQuanYi Zen Hei'])
|
|
if Path(fm.findfont(fp)).name != 'wqy-zenhei.ttc':
|
|
pytest.skip('Font wqy-zenhei.ttc may be missing')
|
|
|
|
fonts = ['WenQuanYi Zen Hei', 'WenQuanYi Zen Hei Mono']
|
|
plt.rc('font', size=16)
|
|
plt.rc('pdf', fonttype=3)
|
|
|
|
figs = plt.figure(figsize=(7, len(fonts) / 2)).subfigures(len(fonts))
|
|
for font, fig in zip(fonts, figs):
|
|
fig.text(0.5, 0.5, f'{font}: {string.ascii_uppercase}', font=font,
|
|
horizontalalignment='center', verticalalignment='center')
|
|
|
|
|
|
@image_comparison(['ttc_type42.pdf'], style='mpl20')
|
|
def test_ttc_type42():
|
|
fp = fm.FontProperties(family=['WenQuanYi Zen Hei'])
|
|
if Path(fm.findfont(fp)).name != 'wqy-zenhei.ttc':
|
|
pytest.skip('Font wqy-zenhei.ttc may be missing')
|
|
|
|
fonts = ['WenQuanYi Zen Hei', 'WenQuanYi Zen Hei Mono']
|
|
plt.rc('font', size=16)
|
|
plt.rc('pdf', fonttype=42)
|
|
|
|
figs = plt.figure(figsize=(7, len(fonts) / 2)).subfigures(len(fonts))
|
|
for font, fig in zip(fonts, figs):
|
|
fig.text(0.5, 0.5, f'{font}: {string.ascii_uppercase}', font=font,
|
|
horizontalalignment='center', verticalalignment='center')
|
|
|
|
|
|
@pytest.mark.parametrize('family_name, file_name',
|
|
[("Noto Sans", "NotoSans-Regular.otf"),
|
|
("FreeMono", "FreeMono.otf")])
|
|
def test_otf_font_smoke(family_name, file_name):
|
|
# checks that there's no segfault
|
|
fp = fm.FontProperties(family=[family_name])
|
|
if Path(fm.findfont(fp)).name != file_name:
|
|
pytest.skip(f"Font {family_name} may be missing")
|
|
|
|
plt.rc('font', family=[family_name], size=27)
|
|
|
|
fig = plt.figure()
|
|
fig.text(0.15, 0.475, "Привет мир!")
|
|
fig.savefig(io.BytesIO(), format="pdf")
|
|
|
|
|
|
@image_comparison(["truetype-conversion.pdf"], style='mpl20')
|
|
# mpltest.ttf does not have "l"/"p" glyphs so we get a warning when trying to
|
|
# get the font extents.
|
|
def test_truetype_conversion(recwarn):
|
|
mpl.rcParams['pdf.fonttype'] = 3
|
|
fig, ax = plt.subplots()
|
|
ax.text(0, 0, "ABCDE",
|
|
font=Path(__file__).parent / "data/mpltest.ttf", fontsize=72)
|
|
ax.set_xticks([])
|
|
ax.set_yticks([])
|
|
|
|
|
|
@pytest.mark.skipif(not _has_tex_package("heuristica"),
|
|
reason="LaTeX lacks heuristica package")
|
|
@image_comparison(["font-heuristica.pdf"], style='_classic_test')
|
|
def test_font_heuristica():
|
|
# Heuristica uses the callothersubr operator for some glyphs
|
|
mpl.rcParams['text.latex.preamble'] = '\n'.join((
|
|
r'\usepackage{heuristica}',
|
|
r'\usepackage[T1]{fontenc}',
|
|
r'\usepackage[utf8]{inputenc}'
|
|
))
|
|
fig, ax = plt.subplots()
|
|
ax.text(0.1, 0.1, r"BHTem fi ffl 1234", usetex=True, fontsize=50)
|
|
ax.set_xticks([])
|
|
ax.set_yticks([])
|
|
|
|
|
|
@pytest.mark.skipif(not _has_tex_package("DejaVuSans"),
|
|
reason="LaTeX lacks DejaVuSans package")
|
|
@image_comparison(["font-dejavusans.pdf"], style='_classic_test')
|
|
def test_font_dejavusans():
|
|
# DejaVuSans uses the seac operator to compose characters with diacritics
|
|
mpl.rcParams['text.latex.preamble'] = '\n'.join((
|
|
r'\usepackage{DejaVuSans}',
|
|
r'\usepackage[T1]{fontenc}',
|
|
r'\usepackage[utf8]{inputenc}'
|
|
))
|
|
|
|
fig, ax = plt.subplots()
|
|
ax.text(0.1, 0.1, r"\textsf{ñäö ABCDabcd}", usetex=True, fontsize=50)
|
|
ax.text(0.1, 0.3, r"\textsf{fi ffl 1234}", usetex=True, fontsize=50)
|
|
ax.set_xticks([])
|
|
ax.set_yticks([])
|
|
|
|
|
|
@pytest.mark.skipif(not _has_tex_package("charter"),
|
|
reason="LaTeX lacks charter package")
|
|
@image_comparison(["font-bitstream-charter.pdf"], style='_classic_test')
|
|
def test_font_bitstream_charter():
|
|
mpl.rcParams['text.latex.preamble'] = '\n'.join((
|
|
r'\usepackage{charter}',
|
|
r'\usepackage[T1]{fontenc}',
|
|
r'\usepackage[utf8]{inputenc}'
|
|
))
|
|
fig, ax = plt.subplots()
|
|
ax.text(0.1, 0.1, r"åüš ABCDabcd", usetex=True, fontsize=50)
|
|
ax.text(0.1, 0.3, r"fi ffl 1234", usetex=True, fontsize=50)
|
|
ax.set_xticks([])
|
|
ax.set_yticks([])
|
|
|
|
|
|
def test_scatter_offaxis_colored_pdf_size():
|
|
"""
|
|
Test that off-axis scatter plots with per-point colors don't bloat PDFs.
|
|
|
|
Regression test for issue #2488. When scatter points with per-point colors
|
|
are completely outside the visible axes, the PDF backend should skip
|
|
writing those markers to significantly reduce file size.
|
|
"""
|
|
# Use John Hunter's birthday as random seed for reproducibility
|
|
rng = np.random.default_rng(19680801)
|
|
|
|
n_points = 1000
|
|
x = rng.random(n_points) * 10
|
|
y = rng.random(n_points) * 10
|
|
c = rng.random(n_points)
|
|
|
|
# Test 1: Scatter with per-point colors, all points OFF-AXIS
|
|
fig1, ax1 = plt.subplots()
|
|
ax1.scatter(x, y, c=c)
|
|
ax1.set_xlim(20, 30) # Move view completely away from data (x is 0-10)
|
|
ax1.set_ylim(20, 30) # Move view completely away from data (y is 0-10)
|
|
|
|
buf1 = io.BytesIO()
|
|
fig1.savefig(buf1, format='pdf')
|
|
size_offaxis_colored = buf1.tell()
|
|
plt.close(fig1)
|
|
|
|
# Test 2: Empty scatter (baseline - accounts for scatter call overhead)
|
|
fig2, ax2 = plt.subplots()
|
|
ax2.scatter([], []) # Empty scatter to match the axes structure
|
|
ax2.set_xlim(20, 30)
|
|
ax2.set_ylim(20, 30)
|
|
|
|
buf2 = io.BytesIO()
|
|
fig2.savefig(buf2, format='pdf')
|
|
size_empty = buf2.tell()
|
|
plt.close(fig2)
|
|
|
|
# Test 3: Scatter with visible markers (should be much larger)
|
|
fig3, ax3 = plt.subplots()
|
|
ax3.scatter(x + 20, y + 20, c=c) # Shift points to be visible
|
|
ax3.set_xlim(20, 30)
|
|
ax3.set_ylim(20, 30)
|
|
|
|
buf3 = io.BytesIO()
|
|
fig3.savefig(buf3, format='pdf')
|
|
size_visible = buf3.tell()
|
|
plt.close(fig3)
|
|
|
|
# The off-axis colored scatter should be close to empty size.
|
|
# Since the axes are identical, the difference should be minimal
|
|
# (just the scatter collection setup, no actual marker data).
|
|
# Use a tight tolerance since axes output is identical.
|
|
assert size_offaxis_colored < size_empty + 5_000, (
|
|
f"Off-axis colored scatter PDF ({size_offaxis_colored} bytes) is too large. "
|
|
f"Expected close to empty scatter size ({size_empty} bytes). "
|
|
f"Markers may not be properly skipped."
|
|
)
|
|
|
|
# The visible scatter should be significantly larger than both empty and
|
|
# off-axis, demonstrating the optimization is working.
|
|
assert size_visible > size_empty + 15_000, (
|
|
f"Visible scatter PDF ({size_visible} bytes) should be much larger "
|
|
f"than empty ({size_empty} bytes) to validate the test."
|
|
)
|
|
assert size_visible > size_offaxis_colored + 15_000, (
|
|
f"Visible scatter PDF ({size_visible} bytes) should be much larger "
|
|
f"than off-axis ({size_offaxis_colored} bytes) to validate optimization."
|
|
)
|
|
|
|
|
|
@check_figures_equal(extensions=["pdf"])
|
|
def test_scatter_offaxis_colored_visual(fig_test, fig_ref):
|
|
"""
|
|
Test that on-axis scatter with per-point colors still renders correctly.
|
|
|
|
Ensures the optimization for off-axis markers doesn't break normal
|
|
scatter rendering.
|
|
"""
|
|
rng = np.random.default_rng(19680801)
|
|
|
|
n_points = 100
|
|
x = rng.random(n_points) * 5
|
|
y = rng.random(n_points) * 5
|
|
c = rng.random(n_points)
|
|
|
|
# Test figure: scatter with clipping optimization
|
|
ax_test = fig_test.subplots()
|
|
ax_test.scatter(x, y, c=c, s=50)
|
|
ax_test.set_xlim(0, 10)
|
|
ax_test.set_ylim(0, 10)
|
|
|
|
# Reference figure: should look identical
|
|
ax_ref = fig_ref.subplots()
|
|
ax_ref.scatter(x, y, c=c, s=50)
|
|
ax_ref.set_xlim(0, 10)
|
|
ax_ref.set_ylim(0, 10)
|
|
|
|
|
|
@check_figures_equal(extensions=["pdf"])
|
|
def test_scatter_mixed_onoff_axis(fig_test, fig_ref):
|
|
"""
|
|
Test scatter with some points on-axis and some off-axis.
|
|
|
|
Ensures the optimization correctly handles the common case where only
|
|
some markers are outside the visible area.
|
|
"""
|
|
rng = np.random.default_rng(19680801)
|
|
|
|
# Create points: half on-axis (0-5), half off-axis (15-20)
|
|
n_points = 50
|
|
x_on = rng.random(n_points) * 5
|
|
y_on = rng.random(n_points) * 5
|
|
x_off = rng.random(n_points) * 5 + 15
|
|
y_off = rng.random(n_points) * 5 + 15
|
|
|
|
x = np.concatenate([x_on, x_off])
|
|
y = np.concatenate([y_on, y_off])
|
|
c = rng.random(2 * n_points)
|
|
|
|
# Test figure: scatter with mixed points
|
|
ax_test = fig_test.subplots()
|
|
ax_test.scatter(x, y, c=c, s=50)
|
|
ax_test.set_xlim(0, 10)
|
|
ax_test.set_ylim(0, 10)
|
|
|
|
# Reference figure: only the on-axis points should be visible
|
|
ax_ref = fig_ref.subplots()
|
|
ax_ref.scatter(x_on, y_on, c=c[:n_points], s=50)
|
|
ax_ref.set_xlim(0, 10)
|
|
ax_ref.set_ylim(0, 10)
|
|
|
|
|
|
@check_figures_equal(extensions=["pdf"])
|
|
def test_scatter_large_markers_partial_clip(fig_test, fig_ref):
|
|
"""
|
|
Test that large markers are rendered when partially visible.
|
|
|
|
Addresses reviewer concern: markers with centers outside the canvas but
|
|
with edges extending into the visible area should still be rendered.
|
|
"""
|
|
# Create markers just outside the visible area
|
|
# Canvas is 0-10, markers at x=-0.5 and x=10.5
|
|
x = np.array([-0.5, 10.5, 5]) # left edge, right edge, center
|
|
y = np.array([5, 5, -0.5]) # center, center, bottom edge
|
|
c = np.array([0.2, 0.5, 0.8])
|
|
|
|
# Test figure: large markers (s=500 ≈ 11 points radius)
|
|
# Centers are outside, but marker edges extend into visible area
|
|
ax_test = fig_test.subplots()
|
|
ax_test.scatter(x, y, c=c, s=500)
|
|
ax_test.set_xlim(0, 10)
|
|
ax_test.set_ylim(0, 10)
|
|
|
|
# Reference figure: same plot (should render identically)
|
|
ax_ref = fig_ref.subplots()
|
|
ax_ref.scatter(x, y, c=c, s=500)
|
|
ax_ref.set_xlim(0, 10)
|
|
ax_ref.set_ylim(0, 10)
|
|
|
|
|
|
@check_figures_equal(extensions=["pdf"])
|
|
def test_scatter_logscale(fig_test, fig_ref):
|
|
"""
|
|
Test scatter optimization with logarithmic scales.
|
|
|
|
Ensures bounds checking works correctly in log-transformed coordinates.
|
|
"""
|
|
rng = np.random.default_rng(19680801)
|
|
|
|
# Create points across several orders of magnitude
|
|
n_points = 50
|
|
x = 10 ** (rng.random(n_points) * 4) # 1 to 10000
|
|
y = 10 ** (rng.random(n_points) * 4)
|
|
c = rng.random(n_points)
|
|
|
|
# Test figure: log scale with points mostly outside view
|
|
ax_test = fig_test.subplots()
|
|
ax_test.scatter(x, y, c=c, s=50)
|
|
ax_test.set_xscale('log')
|
|
ax_test.set_yscale('log')
|
|
ax_test.set_xlim(100, 1000) # Only show middle range
|
|
ax_test.set_ylim(100, 1000)
|
|
|
|
# Reference figure: should render identically
|
|
ax_ref = fig_ref.subplots()
|
|
ax_ref.scatter(x, y, c=c, s=50)
|
|
ax_ref.set_xscale('log')
|
|
ax_ref.set_yscale('log')
|
|
ax_ref.set_xlim(100, 1000)
|
|
ax_ref.set_ylim(100, 1000)
|
|
|
|
|
|
@check_figures_equal(extensions=["pdf"])
|
|
def test_scatter_polar(fig_test, fig_ref):
|
|
"""
|
|
Test scatter optimization with polar coordinates.
|
|
|
|
Ensures bounds checking works correctly in polar projections.
|
|
"""
|
|
rng = np.random.default_rng(19680801)
|
|
|
|
n_points = 50
|
|
theta = rng.random(n_points) * 2 * np.pi
|
|
r = rng.random(n_points) * 3
|
|
c = rng.random(n_points)
|
|
|
|
# Test figure: polar projection
|
|
ax_test = fig_test.subplots(subplot_kw={'projection': 'polar'})
|
|
ax_test.scatter(theta, r, c=c, s=50)
|
|
ax_test.set_ylim(0, 2) # Limit radial range
|
|
|
|
# Reference figure: should render identically
|
|
ax_ref = fig_ref.subplots(subplot_kw={'projection': 'polar'})
|
|
ax_ref.scatter(theta, r, c=c, s=50)
|
|
ax_ref.set_ylim(0, 2)
|