|
11 | 11 | import numpy as np |
12 | 12 | import pytest |
13 | 13 |
|
| 14 | +from unittest.mock import MagicMock, patch |
| 15 | + |
14 | 16 | import matplotlib as mpl |
| 17 | +import matplotlib.font_manager as fm_mod |
15 | 18 | from matplotlib.font_manager import ( |
16 | 19 | findfont, findSystemFonts, FontEntry, FontPath, FontProperties, fontManager, |
17 | 20 | json_dump, json_load, get_font, is_opentype_cff_font, |
18 | | - MSUserFontDirectories, ttfFontProperty, |
| 21 | + MSUserFontDirectories, ttfFontProperty, _get_font_alt_names, |
19 | 22 | _get_fontconfig_fonts, _normalize_weight) |
20 | 23 | from matplotlib import cbook, ft2font, pyplot as plt, rc_context, figure as mfigure |
21 | 24 | from matplotlib.testing import subprocess_run_helper, subprocess_run_for_testing |
@@ -400,23 +403,145 @@ def test_get_font_names(): |
400 | 403 | paths_mpl = [cbook._get_data_path('fonts', subdir) for subdir in ['ttf']] |
401 | 404 | fonts_mpl = findSystemFonts(paths_mpl, fontext='ttf') |
402 | 405 | fonts_system = findSystemFonts(fontext='ttf') |
403 | | - ttf_fonts = [] |
| 406 | + ttf_fonts = set() |
404 | 407 | for path in fonts_mpl + fonts_system: |
405 | 408 | try: |
406 | 409 | font = ft2font.FT2Font(path) |
407 | 410 | prop = ttfFontProperty(font) |
408 | | - ttf_fonts.append(prop.name) |
| 411 | + ttf_fonts.add(prop.name) |
409 | 412 | for face_index in range(1, font.num_faces): |
410 | 413 | font = ft2font.FT2Font(path, face_index=face_index) |
411 | 414 | prop = ttfFontProperty(font) |
412 | | - ttf_fonts.append(prop.name) |
| 415 | + ttf_fonts.add(prop.name) |
413 | 416 | except Exception: |
414 | 417 | pass |
415 | | - available_fonts = sorted(list(set(ttf_fonts))) |
416 | | - mpl_font_names = sorted(fontManager.get_font_names()) |
417 | | - assert set(available_fonts) == set(mpl_font_names) |
418 | | - assert len(available_fonts) == len(mpl_font_names) |
419 | | - assert available_fonts == mpl_font_names |
| 418 | + # fontManager may contain additional entries for alternative family names |
| 419 | + # (e.g. typographic family, platform-specific Name ID 1) registered by |
| 420 | + # addfont(), so primary names must be a subset of the manager's names. |
| 421 | + assert ttf_fonts <= set(fontManager.get_font_names()) |
| 422 | + |
| 423 | + |
| 424 | +def test_addfont_alternative_names(tmp_path): |
| 425 | + """ |
| 426 | + Fonts that advertise different family names across platforms or name IDs |
| 427 | + should be registered under all of those names so users can address the font |
| 428 | + by any of them. |
| 429 | +
|
| 430 | + Two real-world patterns are covered: |
| 431 | +
|
| 432 | + - **MS platform ID 1 differs from Mac platform ID 1** (e.g. Ubuntu Light): |
| 433 | + FreeType returns the Mac ID 1 value as ``family_name``; the MS ID 1 |
| 434 | + value ("Ubuntu Light") is an equally valid name that users expect to work. |
| 435 | + - **Name ID 16 (Typographic Family) differs from ID 1** (older fonts): |
| 436 | + some fonts store a broader family name in ID 16. |
| 437 | + """ |
| 438 | + mac_key = (1, 0, 0) |
| 439 | + ms_key = (3, 1, 0x0409) |
| 440 | + |
| 441 | + # Case 1: MS ID1 differs from Mac ID1 (Ubuntu Light pattern) |
| 442 | + # Mac ID1="Test Family" → FreeType family_name (primary) |
| 443 | + # MS ID1="Test Family Light" → alternate name users expect to work |
| 444 | + ubuntu_style_sfnt = { |
| 445 | + (*mac_key, 1): "Test Family".encode("latin-1"), |
| 446 | + (*ms_key, 1): "Test Family Light".encode("utf-16-be"), |
| 447 | + (*mac_key, 2): "Light".encode("latin-1"), |
| 448 | + (*ms_key, 2): "Regular".encode("utf-16-be"), |
| 449 | + } |
| 450 | + fake_font = MagicMock() |
| 451 | + fake_font.get_sfnt.return_value = ubuntu_style_sfnt |
| 452 | + |
| 453 | + assert _get_font_alt_names(fake_font, "Test Family") == [("Test Family Light", 400)] |
| 454 | + assert _get_font_alt_names(fake_font, "Test Family Light") == [ |
| 455 | + ("Test Family", 300)] |
| 456 | + |
| 457 | + # Case 2: ID 16 differs from ID 1 (older typographic-family pattern) |
| 458 | + # ID 17 (typographic subfamily) is absent → defaults to weight 400 |
| 459 | + id16_sfnt = { |
| 460 | + (*mac_key, 1): "Test Family".encode("latin-1"), |
| 461 | + (*ms_key, 1): "Test Family".encode("utf-16-be"), |
| 462 | + (*ms_key, 16): "Test Family Light".encode("utf-16-be"), |
| 463 | + } |
| 464 | + fake_font_id16 = MagicMock() |
| 465 | + fake_font_id16.get_sfnt.return_value = id16_sfnt |
| 466 | + |
| 467 | + assert _get_font_alt_names( |
| 468 | + fake_font_id16, "Test Family" |
| 469 | + ) == [("Test Family Light", 400)] |
| 470 | + |
| 471 | + # Case 3: all entries agree → no alternates |
| 472 | + same_sfnt = { |
| 473 | + (*mac_key, 1): "Test Family".encode("latin-1"), |
| 474 | + (*ms_key, 1): "Test Family".encode("utf-16-be"), |
| 475 | + } |
| 476 | + fake_font_same = MagicMock() |
| 477 | + fake_font_same.get_sfnt.return_value = same_sfnt |
| 478 | + assert _get_font_alt_names(fake_font_same, "Test Family") == [] |
| 479 | + |
| 480 | + # Case 4: get_sfnt() raises ValueError (e.g. non-SFNT font) → empty list |
| 481 | + fake_font_no_sfnt = MagicMock() |
| 482 | + fake_font_no_sfnt.get_sfnt.side_effect = ValueError |
| 483 | + assert _get_font_alt_names(fake_font_no_sfnt, "Test Family") == [] |
| 484 | + |
| 485 | + fake_path = str(tmp_path / "fake.ttf") |
| 486 | + primary_entry = FontEntry(fname=fake_path, name="Test Family", |
| 487 | + style="normal", variant="normal", |
| 488 | + weight=300, stretch="normal", size="scalable") |
| 489 | + |
| 490 | + with patch("matplotlib.font_manager.ft2font.FT2Font", |
| 491 | + return_value=fake_font), \ |
| 492 | + patch("matplotlib.font_manager.ttfFontProperty", |
| 493 | + return_value=primary_entry): |
| 494 | + fm_instance = fm_mod.FontManager.__new__(fm_mod.FontManager) |
| 495 | + fm_instance.ttflist = [] |
| 496 | + fm_instance.afmlist = [] |
| 497 | + fm_instance._findfont_cached = MagicMock() |
| 498 | + fm_instance._findfont_cached.cache_clear = MagicMock() |
| 499 | + fm_instance.addfont(fake_path) |
| 500 | + |
| 501 | + names = [e.name for e in fm_instance.ttflist] |
| 502 | + assert names == ["Test Family", "Test Family Light"] |
| 503 | + alt_entry = fm_instance.ttflist[1] |
| 504 | + assert alt_entry.weight == 400 |
| 505 | + assert alt_entry.style == primary_entry.style |
| 506 | + assert alt_entry.fname == primary_entry.fname |
| 507 | + |
| 508 | + |
| 509 | +@pytest.mark.parametrize("subfam,expected", [ |
| 510 | + ("Thin", 100), |
| 511 | + ("ExtraLight", 200), |
| 512 | + ("UltraLight", 200), |
| 513 | + ("DemiLight", 350), |
| 514 | + ("SemiLight", 350), |
| 515 | + ("Light", 300), |
| 516 | + ("Book", 380), |
| 517 | + ("Regular", 400), |
| 518 | + ("Normal", 400), |
| 519 | + ("Medium", 500), |
| 520 | + ("DemiBold", 600), |
| 521 | + ("Demi", 600), |
| 522 | + ("SemiBold", 600), |
| 523 | + ("ExtraBold", 800), |
| 524 | + ("SuperBold", 800), |
| 525 | + ("UltraBold", 800), |
| 526 | + ("Bold", 700), |
| 527 | + ("UltraBlack", 1000), |
| 528 | + ("SuperBlack", 1000), |
| 529 | + ("ExtraBlack", 1000), |
| 530 | + ("Ultra", 1000), |
| 531 | + ("Black", 900), |
| 532 | + ("Heavy", 900), |
| 533 | + ("", 400), # fallback: unrecognised → regular |
| 534 | +]) |
| 535 | +def test_alt_name_weight_from_subfamily(subfam, expected): |
| 536 | + """_get_font_alt_names derives weight from the paired subfamily string.""" |
| 537 | + ms_key = (3, 1, 0x0409) |
| 538 | + fake_font = MagicMock() |
| 539 | + fake_font.get_sfnt.return_value = { |
| 540 | + (*ms_key, 1): "Family Alt".encode("utf-16-be"), |
| 541 | + (*ms_key, 2): subfam.encode("utf-16-be"), |
| 542 | + } |
| 543 | + result = _get_font_alt_names(fake_font, "Family") |
| 544 | + assert result == [("Family Alt", expected)] |
420 | 545 |
|
421 | 546 |
|
422 | 547 | def test_donot_cache_tracebacks(): |
|
0 commit comments