plotpress 0.23.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ """Bundled font metrics.
2
+
3
+ SVG-first rendering means we never rasterize glyphs -- the viewer's renderer
4
+ does that from the ``<text>`` elements we emit. We only need *metrics* (glyph
5
+ advance widths) to size the canvas and align tick/axis labels. Bundled width
6
+ tables keep layout deterministic across machines without a font-file
7
+ dependency.
8
+
9
+ ``text_width`` measures the base-14 metric families -- Helvetica, Times and
10
+ Courier, each in regular / bold / italic / bold-italic -- plus DejaVu Sans.
11
+ That covers the metric-compatible clones too: Arial and Liberation Sans are
12
+ Helvetica, Liberation Serif and Tinos are Times, Liberation Mono and Cousine
13
+ are Courier.
14
+
15
+ Known limitation
16
+ ----------------
17
+ Because layout happens before anything draws the glyphs, plotpress has to
18
+ *predict* how wide text will be, from the bundled tables only:
19
+
20
+ * **Families outside those groups are measured as Helvetica.** Verdana, Tahoma,
21
+ Arial Black and Arial Narrow have proprietary metrics that match nothing
22
+ bundled here, so they still render but their legend boxes and axis margins are
23
+ sized for Helvetica.
24
+ * **Small sizes quantize.** Renderers round glyph advances to whole pixels, so
25
+ even a perfectly matched face drifts a few tenths of a percent at tick-label
26
+ sizes. This is inherent to laying out text you do not rasterize; it shows up
27
+ as a little slack in margins, never as overlap.
28
+
29
+ Lifting the first limitation for arbitrary families means measuring real font
30
+ files, which makes layout depend on which fonts happen to be installed -- the
31
+ very thing these tables exist to avoid. It is therefore offered as an opt-in
32
+ rather than refused: see :mod:`plotpress.fonts.installed` and
33
+ ``Style(measure_installed_fonts=True)``.
34
+
35
+ Module layout
36
+ -------------
37
+ ``families``
38
+ The single registry of font families: which width table measures a CSS
39
+ stack, and which files should draw it. Declared together so layout and the
40
+ raster backend cannot disagree about what a family is.
41
+ ``metrics``
42
+ The bundled width tables and :func:`text_width`. Generated -- see
43
+ ``tools/gen_font_metrics.py`` for the sources.
44
+ ``installed``
45
+ Opt-in measurement of the real font files on this machine.
46
+ """
47
+
48
+ from .families import font_files, resolve_family
49
+ from .metrics import text_width
50
+
51
+ __all__ = ["font_files", "resolve_family", "text_width"]
@@ -0,0 +1,192 @@
1
+ """The one place that knows about font families.
2
+
3
+ Two consumers need to answer questions about a CSS font stack, and they must
4
+ not disagree:
5
+
6
+ * **layout** (``fonts.metrics``) asks *which width table describes this stack*,
7
+ because it sizes margins and legend boxes before anything is drawn;
8
+ * **the raster backend** asks *which font file should draw these glyphs*,
9
+ because PNG export rasterizes them itself.
10
+
11
+ Splitting those tables across two modules is how you get glyphs drawn from a
12
+ face that does not match the space reserved for them. So a family is declared
13
+ once here, with both answers attached, and the fallback chain is derived from
14
+ the declaration rather than written out by hand.
15
+
16
+ The derivation rule: a stack's candidate files always end with faces belonging
17
+ to its own *metric* family. That is what keeps the fallback safe. DejaVu Serif
18
+ is 29% wider than Times and DejaVu Sans is 14% wider than Helvetica, so putting
19
+ either at the end of a chain whose layout was computed from Times or Helvetica
20
+ metrics silently overflows every box on a machine that has no better face
21
+ installed.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections import namedtuple
27
+
28
+ # ``metrics`` is the key into the bundled width tables; ``regular``/``bold`` are
29
+ # candidate file names for the raster backend, best first.
30
+ _Family = namedtuple("_Family", "metrics regular bold")
31
+
32
+ # Faces belonging to each metric family, in rough platform order (macOS,
33
+ # Windows, Linux). Every one of these agrees with the width table it is filed
34
+ # under, which is what makes them safe fallbacks.
35
+ HELVETICA_FILES = (
36
+ "Helvetica.ttc", "Helvetica.ttf",
37
+ "arial.ttf", "Arial.ttf",
38
+ "LiberationSans-Regular.ttf",
39
+ "Arimo-Regular.ttf",
40
+ )
41
+ HELVETICA_FILES_BOLD = (
42
+ "Helvetica-Bold.ttf",
43
+ "arialbd.ttf", "Arial Bold.ttf",
44
+ "LiberationSans-Bold.ttf",
45
+ "Arimo-Bold.ttf",
46
+ )
47
+ TIMES_FILES = (
48
+ "Times New Roman.ttf", "times.ttf",
49
+ "LiberationSerif-Regular.ttf",
50
+ "Tinos-Regular.ttf",
51
+ )
52
+ TIMES_FILES_BOLD = (
53
+ "Times New Roman Bold.ttf", "timesbd.ttf",
54
+ "LiberationSerif-Bold.ttf",
55
+ "Tinos-Bold.ttf",
56
+ )
57
+ # DejaVu Sans Mono earns its place here: it advances 602/1000 em against
58
+ # Courier's flat 600, so it is Courier-metric to within a third of a percent.
59
+ # Its sans and serif siblings are not, and are deliberately absent elsewhere.
60
+ COURIER_FILES = (
61
+ "Courier New.ttf", "cour.ttf",
62
+ "LiberationMono-Regular.ttf",
63
+ "Cousine-Regular.ttf",
64
+ "DejaVuSansMono.ttf",
65
+ )
66
+ COURIER_FILES_BOLD = (
67
+ "Courier New Bold.ttf", "courbd.ttf",
68
+ "LiberationMono-Bold.ttf",
69
+ "Cousine-Bold.ttf",
70
+ "DejaVuSansMono-Bold.ttf",
71
+ )
72
+ DEJAVU_FILES = ("DejaVuSans.ttf",)
73
+ DEJAVU_FILES_BOLD = ("DejaVuSans-Bold.ttf",)
74
+
75
+ _METRIC_FILES = {
76
+ "helvetica": (HELVETICA_FILES, HELVETICA_FILES_BOLD),
77
+ "times": (TIMES_FILES, TIMES_FILES_BOLD),
78
+ "courier": (COURIER_FILES, COURIER_FILES_BOLD),
79
+ "dejavu sans": (DEJAVU_FILES, DEJAVU_FILES_BOLD),
80
+ }
81
+
82
+ DEFAULT_METRIC_FAMILY = "helvetica"
83
+
84
+ _FAMILIES = {
85
+ # -- Helvetica and its metric-compatible clones -----------------------
86
+ "helvetica": _Family("helvetica", HELVETICA_FILES, HELVETICA_FILES_BOLD),
87
+ "helvetica neue": _Family("helvetica", ("HelveticaNeue.ttc",),
88
+ ("HelveticaNeue-Bold.ttf",)),
89
+ "arial": _Family("helvetica", ("arial.ttf", "Arial.ttf"),
90
+ ("arialbd.ttf", "Arial Bold.ttf")),
91
+ "liberation sans": _Family("helvetica", ("LiberationSans-Regular.ttf",),
92
+ ("LiberationSans-Bold.ttf",)),
93
+ "arimo": _Family("helvetica", ("Arimo-Regular.ttf",), ("Arimo-Bold.ttf",)),
94
+ "nimbus sans": _Family("helvetica", ("NimbusSans-Regular.otf",),
95
+ ("NimbusSans-Bold.otf",)),
96
+ "sans-serif": _Family("helvetica", HELVETICA_FILES, HELVETICA_FILES_BOLD),
97
+
98
+ # -- Times and its metric-compatible clones ---------------------------
99
+ "times": _Family("times", TIMES_FILES, TIMES_FILES_BOLD),
100
+ "times new roman": _Family("times", ("Times New Roman.ttf", "times.ttf"),
101
+ ("Times New Roman Bold.ttf", "timesbd.ttf")),
102
+ "liberation serif": _Family("times", ("LiberationSerif-Regular.ttf",),
103
+ ("LiberationSerif-Bold.ttf",)),
104
+ "tinos": _Family("times", ("Tinos-Regular.ttf",), ("Tinos-Bold.ttf",)),
105
+ "nimbus roman": _Family("times", ("NimbusRoman-Regular.otf",),
106
+ ("NimbusRoman-Bold.otf",)),
107
+ "serif": _Family("times", TIMES_FILES, TIMES_FILES_BOLD),
108
+
109
+ # -- Courier and its metric-compatible clones -------------------------
110
+ "courier": _Family("courier", COURIER_FILES, COURIER_FILES_BOLD),
111
+ "courier new": _Family("courier", ("Courier New.ttf", "cour.ttf"),
112
+ ("Courier New Bold.ttf", "courbd.ttf")),
113
+ "liberation mono": _Family("courier", ("LiberationMono-Regular.ttf",),
114
+ ("LiberationMono-Bold.ttf",)),
115
+ "cousine": _Family("courier", ("Cousine-Regular.ttf",), ("Cousine-Bold.ttf",)),
116
+ "nimbus mono": _Family("courier", ("NimbusMonoPS-Regular.otf",),
117
+ ("NimbusMonoPS-Bold.otf",)),
118
+ "monospace": _Family("courier", COURIER_FILES, COURIER_FILES_BOLD),
119
+
120
+ # -- DejaVu -----------------------------------------------------------
121
+ "dejavu sans": _Family("dejavu sans", DEJAVU_FILES, DEJAVU_FILES_BOLD),
122
+
123
+ # -- Declared, but not measurable -------------------------------------
124
+ # Proprietary metrics that match no bundled table, so layout measures them
125
+ # as Helvetica -- see the limitations docs. They are listed anyway so PNG
126
+ # export can still draw their real glyphs where the system has them; the
127
+ # derivation rule then tails the chain with Helvetica-metric faces rather
128
+ # than with whatever happens to look similar.
129
+ "verdana": _Family("helvetica", ("verdana.ttf", "Verdana.ttf"),
130
+ ("verdanab.ttf", "Verdana Bold.ttf")),
131
+ "tahoma": _Family("helvetica", ("tahoma.ttf", "Tahoma.ttf"),
132
+ ("tahomabd.ttf", "Tahoma Bold.ttf")),
133
+ "arial narrow": _Family("helvetica", ("ARIALN.TTF", "Arial Narrow.ttf"),
134
+ ("ARIALNB.TTF", "Arial Narrow Bold.ttf")),
135
+ "arial black": _Family("helvetica", ("ariblk.ttf", "Arial Black.ttf"), ()),
136
+ }
137
+
138
+
139
+ def _names(family):
140
+ """The stack, lowercased and unquoted, in order."""
141
+ return [n.strip().strip("'\"").lower()
142
+ for n in (family or "").split(",") if n.strip()]
143
+
144
+
145
+ def resolve_family(family):
146
+ """The metric family describing the first recognized name in a CSS stack.
147
+
148
+ Unknown names are skipped, so a stack naming an unmeasurable face first
149
+ still finds a measurable fallback behind it. A stack with no recognized
150
+ name at all resolves to Helvetica, which is what layout has always assumed.
151
+ """
152
+ for name in _names(family):
153
+ entry = _FAMILIES.get(name)
154
+ if entry is not None:
155
+ return entry.metrics
156
+ return DEFAULT_METRIC_FAMILY
157
+
158
+
159
+ def font_files(family, bold=False):
160
+ """Candidate font files for a CSS stack, best first.
161
+
162
+ The chain is: the faces each named family actually is, then the faces of
163
+ the metric family layout measured it with, then -- for a bold request --
164
+ the same chain at regular weight, since the right glyphs at the wrong
165
+ weight beat the wrong glyphs entirely.
166
+
167
+ It ends with the Helvetica faces as a last resort. For a non-Helvetica
168
+ metric family that is a compromise rather than a match: it keeps a machine
169
+ with no serif or mono face installed rendering, but Helvetica glyphs are
170
+ wider than the Times metrics the layout used, so text can overflow. It
171
+ still beats Pillow's built-in bitmap default, which is both wider and
172
+ unscalable.
173
+ """
174
+ out = []
175
+
176
+ def add(names):
177
+ for f in names:
178
+ if f not in out:
179
+ out.append(f)
180
+
181
+ for name in _names(family):
182
+ entry = _FAMILIES.get(name)
183
+ if entry is not None:
184
+ add(entry.bold if bold else entry.regular)
185
+
186
+ regular_files, bold_files = _METRIC_FILES[resolve_family(family)]
187
+ add(bold_files if bold else regular_files)
188
+ if bold:
189
+ add(font_files(family, bold=False))
190
+ add(HELVETICA_FILES_BOLD if bold else ())
191
+ add(HELVETICA_FILES)
192
+ return out
@@ -0,0 +1,82 @@
1
+ """Opt-in measurement of the fonts actually installed on this machine.
2
+
3
+ Off by default, and deliberately so. plotpress's bundled width tables exist to
4
+ make layout *deterministic*: the same script produces the same margins on every
5
+ machine, because it never asks the machine anything. Turning this on trades that
6
+ guarantee for fidelity, and it is a real trade -- a figure laid out here may not
7
+ match one laid out on a colleague's box, or on CI.
8
+
9
+ Turn it on when the fidelity is worth more than the reproducibility: you are
10
+ setting ``Style.font_family`` to a face plotpress cannot measure (Verdana,
11
+ Tahoma, Arial Black, Arial Narrow) and you would rather have correct margins on
12
+ your own machine than portable ones.
13
+
14
+ fig, ax = plotpress.subplots(
15
+ style=plotpress.Style(font_family="Verdana, sans-serif",
16
+ measure_installed_fonts=True))
17
+
18
+ Implementation note: this needs no new dependency. Pillow is already required
19
+ for PNG export, it already resolves a bare font file name against the system
20
+ font directories, and it already measures glyph advances -- the same machinery
21
+ the raster backend draws with. Reusing it is what keeps layout and PNG output
22
+ agreeing about what a face is: both go through
23
+ :func:`plotpress.fonts.families.font_files`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ # Measured once at a large em square and scaled down, rather than at each label's
29
+ # real size: advances are linear in size, so one probe serves every size, and a
30
+ # big probe makes per-glyph integer rounding negligible.
31
+ _PROBE_EM = 1000
32
+
33
+ _ASCII = [chr(c) for c in range(32, 127)]
34
+
35
+ _table_cache = {}
36
+
37
+
38
+ def installed_table(family, bold=False, italic=False):
39
+ """``{char: advance per 1000 em}`` from a real font file, or ``None``.
40
+
41
+ Returns ``None`` -- meaning "fall back to the bundled tables" -- when no
42
+ candidate file resolves, or when Pillow was built without FreeType and so
43
+ cannot open a scalable face at all.
44
+
45
+ ``italic`` is accepted for signature parity with the bundled path but does
46
+ not select a different file: the family registry tracks weight, not slant,
47
+ since nothing in the default styling draws italic.
48
+ """
49
+ key = (family, bool(bold))
50
+ if key in _table_cache:
51
+ return _table_cache[key]
52
+
53
+ table = _measure(family, bool(bold))
54
+ _table_cache[key] = table
55
+ return table
56
+
57
+
58
+ def _measure(family, bold):
59
+ try:
60
+ from PIL import ImageFont
61
+ except ImportError: # pragma: no cover - Pillow is required
62
+ return None
63
+
64
+ from .families import font_files
65
+
66
+ for name in font_files(family, bold):
67
+ try:
68
+ font = ImageFont.truetype(name, _PROBE_EM)
69
+ except (OSError, ImportError):
70
+ # Not installed here, or this Pillow has no FreeType. Either way the
71
+ # next candidate might still work.
72
+ continue
73
+ try:
74
+ return {ch: font.getlength(ch) for ch in _ASCII}
75
+ except (OSError, ValueError): # pragma: no cover - malformed face
76
+ continue
77
+ return None
78
+
79
+
80
+ def clear_cache():
81
+ """Forget measured faces. Only useful in tests, or if fonts change on disk."""
82
+ _table_cache.clear()
@@ -0,0 +1,265 @@
1
+ """Advance-width tables for the fonts plotpress can measure.
2
+
3
+ GENERATED by ``tools/gen_font_metrics.py`` -- do not edit by hand.
4
+
5
+ Only the metric matters for layout: we place ``<text>`` and let the renderer
6
+ draw glyphs. ``text_width`` estimates a string's rendered width so labels can be
7
+ centered / right-aligned and margins sized.
8
+
9
+ Widths are in 1/1000 em. Sources are the URW base-14 clones (metric-compatible
10
+ with Adobe Helvetica / Times / Courier) and the DejaVu Sans TTFs; see the
11
+ generator for why they are read by glyph name rather than by character code.
12
+
13
+ Which table describes a given CSS font stack is decided in ``families.py``,
14
+ alongside which files should draw it -- one declaration, so layout and the
15
+ raster backend cannot disagree.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from .families import DEFAULT_METRIC_FAMILY, resolve_family
21
+
22
+ _DEJAVU_SANS = {
23
+ ' ': 318, '!': 401, '"': 460, '#': 838, '$': 636, '%': 950, '&': 780, "'": 275,
24
+ '(': 390, ')': 390, '*': 500, '+': 838, ',': 318, '-': 361, '.': 318, '/': 337,
25
+ '0': 636, '1': 636, '2': 636, '3': 636, '4': 636, '5': 636, '6': 636, '7': 636,
26
+ '8': 636, '9': 636, ':': 337, ';': 337, '<': 838, '=': 838, '>': 838, '?': 531,
27
+ '@': 1000, 'A': 684, 'B': 686, 'C': 698, 'D': 770, 'E': 632, 'F': 575, 'G': 775,
28
+ 'H': 752, 'I': 295, 'J': 295, 'K': 656, 'L': 557, 'M': 863, 'N': 748, 'O': 787,
29
+ 'P': 603, 'Q': 787, 'R': 695, 'S': 635, 'T': 611, 'U': 732, 'V': 684, 'W': 989,
30
+ 'X': 685, 'Y': 611, 'Z': 685, '[': 390, '\\': 337, ']': 390, '^': 838, '_': 500,
31
+ '`': 500, 'a': 613, 'b': 635, 'c': 550, 'd': 635, 'e': 615, 'f': 352, 'g': 635,
32
+ 'h': 634, 'i': 278, 'j': 278, 'k': 579, 'l': 278, 'm': 974, 'n': 634, 'o': 612,
33
+ 'p': 635, 'q': 635, 'r': 411, 's': 521, 't': 392, 'u': 634, 'v': 592, 'w': 818,
34
+ 'x': 592, 'y': 592, 'z': 525, '{': 636, '|': 337, '}': 636, '~': 838,
35
+ }
36
+
37
+ _DEJAVU_SANS_ITALIC = {
38
+ ' ': 318, '!': 401, '"': 460, '#': 838, '$': 636, '%': 950, '&': 780, "'": 275,
39
+ '(': 390, ')': 390, '*': 500, '+': 838, ',': 318, '-': 361, '.': 318, '/': 337,
40
+ '0': 636, '1': 636, '2': 636, '3': 636, '4': 636, '5': 636, '6': 636, '7': 636,
41
+ '8': 636, '9': 636, ':': 337, ';': 337, '<': 838, '=': 838, '>': 838, '?': 531,
42
+ '@': 1000, 'A': 684, 'B': 686, 'C': 698, 'D': 770, 'E': 632, 'F': 575, 'G': 775,
43
+ 'H': 752, 'I': 295, 'J': 295, 'K': 656, 'L': 557, 'M': 863, 'N': 748, 'O': 787,
44
+ 'P': 603, 'Q': 787, 'R': 695, 'S': 635, 'T': 611, 'U': 732, 'V': 684, 'W': 989,
45
+ 'X': 685, 'Y': 611, 'Z': 685, '[': 390, '\\': 337, ']': 390, '^': 838, '_': 500,
46
+ '`': 500, 'a': 613, 'b': 635, 'c': 550, 'd': 635, 'e': 615, 'f': 352, 'g': 635,
47
+ 'h': 634, 'i': 278, 'j': 278, 'k': 579, 'l': 278, 'm': 974, 'n': 634, 'o': 612,
48
+ 'p': 635, 'q': 635, 'r': 411, 's': 521, 't': 392, 'u': 634, 'v': 592, 'w': 818,
49
+ 'x': 592, 'y': 592, 'z': 525, '{': 636, '|': 337, '}': 636, '~': 838,
50
+ }
51
+
52
+ _DEJAVU_SANS_BOLD = {
53
+ ' ': 348, '!': 456, '"': 521, '#': 838, '$': 696, '%': 1002, '&': 872, "'": 306,
54
+ '(': 457, ')': 457, '*': 523, '+': 838, ',': 380, '-': 415, '.': 380, '/': 365,
55
+ '0': 696, '1': 696, '2': 696, '3': 696, '4': 696, '5': 696, '6': 696, '7': 696,
56
+ '8': 696, '9': 696, ':': 400, ';': 400, '<': 838, '=': 838, '>': 838, '?': 580,
57
+ '@': 1000, 'A': 774, 'B': 762, 'C': 734, 'D': 830, 'E': 683, 'F': 683, 'G': 821,
58
+ 'H': 837, 'I': 372, 'J': 372, 'K': 775, 'L': 637, 'M': 995, 'N': 837, 'O': 850,
59
+ 'P': 733, 'Q': 850, 'R': 770, 'S': 720, 'T': 682, 'U': 812, 'V': 774, 'W': 1103,
60
+ 'X': 771, 'Y': 724, 'Z': 725, '[': 457, '\\': 365, ']': 457, '^': 838, '_': 500,
61
+ '`': 500, 'a': 675, 'b': 716, 'c': 593, 'd': 716, 'e': 678, 'f': 435, 'g': 716,
62
+ 'h': 712, 'i': 343, 'j': 343, 'k': 665, 'l': 343, 'm': 1042, 'n': 712, 'o': 687,
63
+ 'p': 716, 'q': 716, 'r': 493, 's': 595, 't': 478, 'u': 712, 'v': 652, 'w': 924,
64
+ 'x': 645, 'y': 652, 'z': 582, '{': 712, '|': 365, '}': 712, '~': 838,
65
+ }
66
+
67
+ _DEJAVU_SANS_BOLD_ITALIC = {
68
+ ' ': 348, '!': 456, '"': 521, '#': 696, '$': 696, '%': 1002, '&': 872, "'": 306,
69
+ '(': 457, ')': 457, '*': 523, '+': 838, ',': 380, '-': 415, '.': 380, '/': 365,
70
+ '0': 696, '1': 696, '2': 696, '3': 696, '4': 696, '5': 696, '6': 696, '7': 696,
71
+ '8': 696, '9': 696, ':': 400, ';': 400, '<': 838, '=': 838, '>': 838, '?': 580,
72
+ '@': 1000, 'A': 774, 'B': 762, 'C': 734, 'D': 830, 'E': 683, 'F': 683, 'G': 821,
73
+ 'H': 837, 'I': 372, 'J': 372, 'K': 775, 'L': 637, 'M': 995, 'N': 837, 'O': 850,
74
+ 'P': 733, 'Q': 850, 'R': 770, 'S': 720, 'T': 682, 'U': 812, 'V': 774, 'W': 1103,
75
+ 'X': 771, 'Y': 724, 'Z': 725, '[': 457, '\\': 365, ']': 457, '^': 838, '_': 500,
76
+ '`': 500, 'a': 675, 'b': 716, 'c': 593, 'd': 716, 'e': 678, 'f': 435, 'g': 716,
77
+ 'h': 712, 'i': 343, 'j': 343, 'k': 665, 'l': 343, 'm': 1042, 'n': 712, 'o': 687,
78
+ 'p': 716, 'q': 716, 'r': 493, 's': 595, 't': 478, 'u': 712, 'v': 652, 'w': 924,
79
+ 'x': 645, 'y': 652, 'z': 582, '{': 712, '|': 365, '}': 712, '~': 838,
80
+ }
81
+
82
+ _HELVETICA = {
83
+ ' ': 278, '!': 278, '"': 355, '#': 556, '$': 556, '%': 889, '&': 667, "'": 191,
84
+ '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278,
85
+ '0': 556, '1': 556, '2': 556, '3': 556, '4': 556, '5': 556, '6': 556, '7': 556,
86
+ '8': 556, '9': 556, ':': 278, ';': 278, '<': 584, '=': 584, '>': 584, '?': 556,
87
+ '@': 1015, 'A': 667, 'B': 667, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778,
88
+ 'H': 722, 'I': 278, 'J': 500, 'K': 667, 'L': 556, 'M': 833, 'N': 722, 'O': 778,
89
+ 'P': 667, 'Q': 778, 'R': 722, 'S': 667, 'T': 611, 'U': 722, 'V': 667, 'W': 944,
90
+ 'X': 667, 'Y': 667, 'Z': 611, '[': 278, '\\': 278, ']': 278, '^': 469, '_': 556,
91
+ '`': 333, 'a': 556, 'b': 556, 'c': 500, 'd': 556, 'e': 556, 'f': 278, 'g': 556,
92
+ 'h': 556, 'i': 222, 'j': 222, 'k': 500, 'l': 222, 'm': 833, 'n': 556, 'o': 556,
93
+ 'p': 556, 'q': 556, 'r': 333, 's': 500, 't': 278, 'u': 556, 'v': 500, 'w': 722,
94
+ 'x': 500, 'y': 500, 'z': 500, '{': 334, '|': 260, '}': 334, '~': 584,
95
+ }
96
+
97
+ _HELVETICA_ITALIC = {
98
+ ' ': 278, '!': 278, '"': 355, '#': 556, '$': 556, '%': 889, '&': 667, "'": 191,
99
+ '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278,
100
+ '0': 556, '1': 556, '2': 556, '3': 556, '4': 556, '5': 556, '6': 556, '7': 556,
101
+ '8': 556, '9': 556, ':': 278, ';': 278, '<': 584, '=': 584, '>': 584, '?': 556,
102
+ '@': 1015, 'A': 667, 'B': 667, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778,
103
+ 'H': 722, 'I': 278, 'J': 500, 'K': 667, 'L': 556, 'M': 833, 'N': 722, 'O': 778,
104
+ 'P': 667, 'Q': 778, 'R': 722, 'S': 667, 'T': 611, 'U': 722, 'V': 667, 'W': 944,
105
+ 'X': 667, 'Y': 667, 'Z': 611, '[': 278, '\\': 278, ']': 278, '^': 469, '_': 556,
106
+ '`': 333, 'a': 556, 'b': 556, 'c': 500, 'd': 556, 'e': 556, 'f': 278, 'g': 556,
107
+ 'h': 556, 'i': 222, 'j': 222, 'k': 500, 'l': 222, 'm': 833, 'n': 556, 'o': 556,
108
+ 'p': 556, 'q': 556, 'r': 333, 's': 500, 't': 278, 'u': 556, 'v': 500, 'w': 722,
109
+ 'x': 500, 'y': 500, 'z': 500, '{': 334, '|': 260, '}': 334, '~': 584,
110
+ }
111
+
112
+ _HELVETICA_BOLD = {
113
+ ' ': 278, '!': 333, '"': 474, '#': 556, '$': 556, '%': 889, '&': 722, "'": 238,
114
+ '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278,
115
+ '0': 556, '1': 556, '2': 556, '3': 556, '4': 556, '5': 556, '6': 556, '7': 556,
116
+ '8': 556, '9': 556, ':': 333, ';': 333, '<': 584, '=': 584, '>': 584, '?': 611,
117
+ '@': 975, 'A': 722, 'B': 722, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778,
118
+ 'H': 722, 'I': 278, 'J': 556, 'K': 722, 'L': 611, 'M': 833, 'N': 722, 'O': 778,
119
+ 'P': 667, 'Q': 778, 'R': 722, 'S': 667, 'T': 611, 'U': 722, 'V': 667, 'W': 944,
120
+ 'X': 667, 'Y': 667, 'Z': 611, '[': 333, '\\': 278, ']': 333, '^': 584, '_': 556,
121
+ '`': 333, 'a': 556, 'b': 611, 'c': 556, 'd': 611, 'e': 556, 'f': 333, 'g': 611,
122
+ 'h': 611, 'i': 278, 'j': 278, 'k': 556, 'l': 278, 'm': 889, 'n': 611, 'o': 611,
123
+ 'p': 611, 'q': 611, 'r': 389, 's': 556, 't': 333, 'u': 611, 'v': 556, 'w': 778,
124
+ 'x': 556, 'y': 556, 'z': 500, '{': 389, '|': 280, '}': 389, '~': 584,
125
+ }
126
+
127
+ _HELVETICA_BOLD_ITALIC = {
128
+ ' ': 278, '!': 333, '"': 474, '#': 556, '$': 556, '%': 889, '&': 722, "'": 238,
129
+ '(': 333, ')': 333, '*': 389, '+': 584, ',': 278, '-': 333, '.': 278, '/': 278,
130
+ '0': 556, '1': 556, '2': 556, '3': 556, '4': 556, '5': 556, '6': 556, '7': 556,
131
+ '8': 556, '9': 556, ':': 333, ';': 333, '<': 584, '=': 584, '>': 584, '?': 611,
132
+ '@': 975, 'A': 722, 'B': 722, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778,
133
+ 'H': 722, 'I': 278, 'J': 556, 'K': 722, 'L': 611, 'M': 833, 'N': 722, 'O': 778,
134
+ 'P': 667, 'Q': 778, 'R': 722, 'S': 667, 'T': 611, 'U': 722, 'V': 667, 'W': 944,
135
+ 'X': 667, 'Y': 667, 'Z': 611, '[': 333, '\\': 278, ']': 333, '^': 584, '_': 556,
136
+ '`': 333, 'a': 556, 'b': 611, 'c': 556, 'd': 611, 'e': 556, 'f': 333, 'g': 611,
137
+ 'h': 611, 'i': 278, 'j': 278, 'k': 556, 'l': 278, 'm': 889, 'n': 611, 'o': 611,
138
+ 'p': 611, 'q': 611, 'r': 389, 's': 556, 't': 333, 'u': 611, 'v': 556, 'w': 778,
139
+ 'x': 556, 'y': 556, 'z': 500, '{': 389, '|': 280, '}': 389, '~': 584,
140
+ }
141
+
142
+ _TIMES = {
143
+ ' ': 250, '!': 333, '"': 408, '#': 500, '$': 500, '%': 833, '&': 778, "'": 180,
144
+ '(': 333, ')': 333, '*': 500, '+': 564, ',': 250, '-': 333, '.': 250, '/': 278,
145
+ '0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500, '7': 500,
146
+ '8': 500, '9': 500, ':': 278, ';': 278, '<': 564, '=': 564, '>': 564, '?': 444,
147
+ '@': 921, 'A': 722, 'B': 667, 'C': 667, 'D': 722, 'E': 611, 'F': 556, 'G': 722,
148
+ 'H': 722, 'I': 333, 'J': 389, 'K': 722, 'L': 611, 'M': 889, 'N': 722, 'O': 722,
149
+ 'P': 556, 'Q': 722, 'R': 667, 'S': 556, 'T': 611, 'U': 722, 'V': 722, 'W': 944,
150
+ 'X': 722, 'Y': 722, 'Z': 611, '[': 333, '\\': 278, ']': 333, '^': 469, '_': 500,
151
+ '`': 333, 'a': 444, 'b': 500, 'c': 444, 'd': 500, 'e': 444, 'f': 333, 'g': 500,
152
+ 'h': 500, 'i': 278, 'j': 278, 'k': 500, 'l': 278, 'm': 778, 'n': 500, 'o': 500,
153
+ 'p': 500, 'q': 500, 'r': 333, 's': 389, 't': 278, 'u': 500, 'v': 500, 'w': 722,
154
+ 'x': 500, 'y': 500, 'z': 444, '{': 480, '|': 200, '}': 480, '~': 541,
155
+ }
156
+
157
+ _TIMES_ITALIC = {
158
+ ' ': 250, '!': 333, '"': 420, '#': 500, '$': 500, '%': 833, '&': 778, "'": 214,
159
+ '(': 333, ')': 333, '*': 500, '+': 675, ',': 250, '-': 333, '.': 250, '/': 278,
160
+ '0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500, '7': 500,
161
+ '8': 500, '9': 500, ':': 333, ';': 333, '<': 675, '=': 675, '>': 675, '?': 500,
162
+ '@': 920, 'A': 611, 'B': 611, 'C': 667, 'D': 722, 'E': 611, 'F': 611, 'G': 722,
163
+ 'H': 722, 'I': 333, 'J': 444, 'K': 667, 'L': 556, 'M': 833, 'N': 667, 'O': 722,
164
+ 'P': 611, 'Q': 722, 'R': 611, 'S': 500, 'T': 556, 'U': 722, 'V': 611, 'W': 833,
165
+ 'X': 611, 'Y': 556, 'Z': 556, '[': 389, '\\': 278, ']': 389, '^': 422, '_': 500,
166
+ '`': 333, 'a': 500, 'b': 500, 'c': 444, 'd': 500, 'e': 444, 'f': 278, 'g': 500,
167
+ 'h': 500, 'i': 278, 'j': 278, 'k': 444, 'l': 278, 'm': 722, 'n': 500, 'o': 500,
168
+ 'p': 500, 'q': 500, 'r': 389, 's': 389, 't': 278, 'u': 500, 'v': 444, 'w': 667,
169
+ 'x': 444, 'y': 444, 'z': 389, '{': 400, '|': 275, '}': 400, '~': 541,
170
+ }
171
+
172
+ _TIMES_BOLD = {
173
+ ' ': 250, '!': 333, '"': 555, '#': 500, '$': 500, '%': 1000, '&': 833, "'": 278,
174
+ '(': 333, ')': 333, '*': 500, '+': 570, ',': 250, '-': 333, '.': 250, '/': 278,
175
+ '0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500, '7': 500,
176
+ '8': 500, '9': 500, ':': 333, ';': 333, '<': 570, '=': 570, '>': 570, '?': 500,
177
+ '@': 930, 'A': 722, 'B': 667, 'C': 722, 'D': 722, 'E': 667, 'F': 611, 'G': 778,
178
+ 'H': 778, 'I': 389, 'J': 500, 'K': 778, 'L': 667, 'M': 944, 'N': 722, 'O': 778,
179
+ 'P': 611, 'Q': 778, 'R': 722, 'S': 556, 'T': 667, 'U': 722, 'V': 722, 'W': 1000,
180
+ 'X': 722, 'Y': 722, 'Z': 667, '[': 333, '\\': 278, ']': 333, '^': 581, '_': 500,
181
+ '`': 333, 'a': 500, 'b': 556, 'c': 444, 'd': 556, 'e': 444, 'f': 333, 'g': 500,
182
+ 'h': 556, 'i': 278, 'j': 333, 'k': 556, 'l': 278, 'm': 833, 'n': 556, 'o': 500,
183
+ 'p': 556, 'q': 556, 'r': 444, 's': 389, 't': 333, 'u': 556, 'v': 500, 'w': 722,
184
+ 'x': 500, 'y': 500, 'z': 444, '{': 394, '|': 220, '}': 394, '~': 520,
185
+ }
186
+
187
+ _TIMES_BOLD_ITALIC = {
188
+ ' ': 250, '!': 389, '"': 555, '#': 500, '$': 500, '%': 833, '&': 778, "'": 278,
189
+ '(': 333, ')': 333, '*': 500, '+': 570, ',': 250, '-': 333, '.': 250, '/': 278,
190
+ '0': 500, '1': 500, '2': 500, '3': 500, '4': 500, '5': 500, '6': 500, '7': 500,
191
+ '8': 500, '9': 500, ':': 333, ';': 333, '<': 570, '=': 570, '>': 570, '?': 500,
192
+ '@': 832, 'A': 667, 'B': 667, 'C': 667, 'D': 722, 'E': 667, 'F': 667, 'G': 722,
193
+ 'H': 778, 'I': 389, 'J': 500, 'K': 667, 'L': 611, 'M': 889, 'N': 722, 'O': 722,
194
+ 'P': 611, 'Q': 722, 'R': 667, 'S': 556, 'T': 611, 'U': 722, 'V': 667, 'W': 889,
195
+ 'X': 667, 'Y': 611, 'Z': 611, '[': 333, '\\': 278, ']': 333, '^': 570, '_': 500,
196
+ '`': 333, 'a': 500, 'b': 500, 'c': 444, 'd': 500, 'e': 444, 'f': 333, 'g': 500,
197
+ 'h': 556, 'i': 278, 'j': 278, 'k': 500, 'l': 278, 'm': 778, 'n': 556, 'o': 500,
198
+ 'p': 500, 'q': 500, 'r': 389, 's': 389, 't': 278, 'u': 556, 'v': 444, 'w': 667,
199
+ 'x': 500, 'y': 444, 'z': 389, '{': 348, '|': 220, '}': 348, '~': 570,
200
+ }
201
+
202
+ # Courier and its variants are monospaced: every ASCII glyph is 600/1000 em.
203
+ _COURIER_ADVANCE = 600
204
+
205
+ # Fallback for characters outside the tables -- anything non-ASCII, so degree
206
+ # signs, Greek and micro. Each family uses its own digit advance: digits are a
207
+ # single uniform width per family and sit mid-range, which makes them a better
208
+ # stand-in than a constant borrowed from Helvetica.
209
+ _DEFAULTS = {
210
+ "helvetica": 556,
211
+ "times": 500,
212
+ "dejavu sans": 636,
213
+ }
214
+
215
+ # (metric family, bold, italic) -> width table. Oblique/italic keys are present
216
+ # for every family, so a lookup never has to fall back across a style boundary.
217
+ _TABLES = {
218
+ ("helvetica", False, False): _HELVETICA,
219
+ ("helvetica", True, False): _HELVETICA_BOLD,
220
+ ("helvetica", False, True): _HELVETICA_ITALIC,
221
+ ("helvetica", True, True): _HELVETICA_BOLD_ITALIC,
222
+ ("times", False, False): _TIMES,
223
+ ("times", True, False): _TIMES_BOLD,
224
+ ("times", False, True): _TIMES_ITALIC,
225
+ ("times", True, True): _TIMES_BOLD_ITALIC,
226
+ ("dejavu sans", False, False): _DEJAVU_SANS,
227
+ ("dejavu sans", True, False): _DEJAVU_SANS_BOLD,
228
+ ("dejavu sans", False, True): _DEJAVU_SANS_ITALIC,
229
+ ("dejavu sans", True, True): _DEJAVU_SANS_BOLD_ITALIC,
230
+ }
231
+
232
+ def text_width(text, font_size, family=None, bold=False, italic=False,
233
+ measure_installed=False):
234
+ """Estimated rendered width of ``text`` in pixels at ``font_size`` px.
235
+
236
+ ``family`` is a CSS font stack; it is resolved to the nearest bundled
237
+ metric family. ``bold`` / ``italic`` select the matching face, which matters
238
+ because bold Helvetica runs several percent wider than regular.
239
+
240
+ ``measure_installed`` opts out of the bundled tables and measures the font
241
+ file actually present on this machine, which is more faithful for families
242
+ plotpress cannot otherwise measure but makes layout depend on what is
243
+ installed. It silently falls back to the bundled tables when no face
244
+ resolves. See :mod:`plotpress.fonts.installed`.
245
+ """
246
+ if measure_installed:
247
+ from .installed import installed_table
248
+
249
+ table = installed_table(family, bold, italic)
250
+ if table is not None:
251
+ default = table.get("0", _DEFAULTS[DEFAULT_METRIC_FAMILY])
252
+ units = 0
253
+ for ch in text:
254
+ units += table.get(ch, default)
255
+ return units / 1000.0 * font_size
256
+
257
+ resolved = resolve_family(family)
258
+ if resolved == "courier":
259
+ return len(text) * _COURIER_ADVANCE / 1000.0 * font_size
260
+ table = _TABLES[(resolved, bool(bold), bool(italic))]
261
+ default = _DEFAULTS[resolved]
262
+ units = 0
263
+ for ch in text:
264
+ units += table.get(ch, default)
265
+ return units / 1000.0 * font_size