codefig 0.17.0__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.
codefig/legend.py ADDED
@@ -0,0 +1,483 @@
1
+ """Legends: keys for fills, hatches, borders and edge styles.
2
+
3
+ ``Legend()`` with no entries auto-collects every ``legend="…"`` label in its
4
+ panel: nodes (their fill / pattern / border), edges and forks (colour, dash,
5
+ arrowhead) and Matrix ``legend={colour: label}`` maps. Pass ``entries=[Key(…)]``
6
+ to list keys by hand (or ``auto=False`` to use only those). A Legend is an
7
+ ordinary node: put it in a Row/Col or ``place()`` it next to anything.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, List, Optional, Sequence, Tuple
13
+
14
+ from .node import Node
15
+ from .styles import themed
16
+ from .textutil import measure_text
17
+
18
+ KINDS = ("box", "line", "arrow", "dot", "mark", "icon")
19
+
20
+
21
+
22
+ class Hatch:
23
+ """Diagonal hatching pattern for ``pattern=`` on Box / Group / Key.
24
+
25
+ angle: degrees (45 = "/", -45 = "\\", 0 = horizontal, 90 = vertical);
26
+ ``cross=True`` adds the perpendicular set.
27
+ """
28
+
29
+ def __init__(self, *, angle: float = 45.0, spacing: Optional[float] = None,
30
+ color: str = "#555555", width: Optional[float] = None, opacity: float = 1.0,
31
+ cross: bool = False) -> None:
32
+ self.angle = float(angle)
33
+ self._spacing = None if spacing is None else float(spacing)
34
+ self.color = color
35
+ self._width = None if width is None else float(width)
36
+ self.opacity = float(opacity)
37
+ self.cross = bool(cross)
38
+
39
+ @property
40
+ def spacing(self) -> float:
41
+ """Line pitch (default ``theme.hatch_spacing``: 5 px classic, 2.8 pt paper)."""
42
+ if self._spacing is not None:
43
+ return self._spacing
44
+ from .styles import current_theme
45
+
46
+ return float(current_theme().hatch_spacing)
47
+
48
+ @spacing.setter
49
+ def spacing(self, v: Optional[float]) -> None:
50
+ self._spacing = None if v is None else float(v)
51
+
52
+ @property
53
+ def width(self) -> float:
54
+ """Line width (default ``theme.hatch_width``: 0.8 px classic, 0.5 pt paper)."""
55
+ if self._width is not None:
56
+ return self._width
57
+ from .styles import current_theme
58
+
59
+ return float(current_theme().hatch_width)
60
+
61
+ @width.setter
62
+ def width(self, v: Optional[float]) -> None:
63
+ self._width = None if v is None else float(v)
64
+
65
+ _seq = 0
66
+
67
+ def paint(self, buf: List[str], x: float, y: float, w: float, h: float) -> None:
68
+ import math
69
+
70
+ Hatch._seq += 1
71
+ cid = f"cf-hatch-{Hatch._seq}"
72
+ buf.append(f'<defs><clipPath id="{cid}"><rect x="{x:.2f}" y="{y:.2f}" width="{w:.2f}" '
73
+ f'height="{h:.2f}"/></clipPath></defs>')
74
+ op = f' stroke-opacity="{self.opacity:.3g}"' if self.opacity < 1 else ""
75
+ segs = []
76
+ cx, cy = x + w / 2, y + h / 2
77
+ R = math.hypot(w, h) / 2 + self.spacing
78
+ for ang in ([self.angle, self.angle + 90] if self.cross else [self.angle]):
79
+ t = math.radians(-ang)
80
+ dx, dy = math.cos(t), math.sin(t) # line direction
81
+ nx, ny = -dy, dx # normal
82
+ n = int(R / self.spacing) + 1
83
+ for k in range(-n, n + 1):
84
+ ox, oy = cx + nx * k * self.spacing, cy + ny * k * self.spacing
85
+ segs.append(f"M {ox - dx * R:.2f},{oy - dy * R:.2f} L {ox + dx * R:.2f},{oy + dy * R:.2f}")
86
+ buf.append(f'<path d="{" ".join(segs)}" stroke="{self.color}" stroke-width="{self.width:.3g}"'
87
+ f' fill="none" clip-path="url(#{cid})"{op} />')
88
+
89
+
90
+ class Key:
91
+ """One legend entry.
92
+
93
+ kind: one of ``"box"`` (fill / pattern / border / hatch), ``"line"`` or ``"arrow"``
94
+ (edge ``color=``, ``style="dashed"|"dotted"|…``, ``width=``, ``head=`` / ``tail=``),
95
+ ``"dot"``, ``"mark"`` (``mark="check"|"cross"|…`` glyph) or ``"icon"`` (``icon=``).
96
+ Unspecified kind is inferred (``style=`` / ``head=`` → line / arrow, ``mark=`` →
97
+ mark, ``icon=`` → icon, else box). Labels may contain ``"\n"``: the row grows.
98
+ Example: ``Key("copy", kind="arrow", color="#0072B2", style="dashed")``.
99
+ """
100
+
101
+ def __init__(self, label: str, *, kind: Optional[str] = None, fill: Optional[str] = None,
102
+ stroke: Optional[str] = None, border: Any = None, pattern: Any = None,
103
+ hatch: Any = None, color: Optional[str] = None, style: Any = None,
104
+ width: Optional[float] = None, head: Any = None, tail: Any = None,
105
+ hollow: Optional[bool] = None, mark: Optional[str] = None,
106
+ radius: float = 1.5, icon: Any = None) -> None:
107
+ if hatch is not None and pattern is None:
108
+ pattern = hatch if not isinstance(hatch, (bool, int, float, str)) else Hatch(
109
+ angle=45.0 if hatch in (True, "/", "diag") else (
110
+ -45.0 if hatch == "\\" else float(hatch) if isinstance(hatch, (int, float))
111
+ else 45.0))
112
+ if icon is not None and not hasattr(icon, "paint"):
113
+ from .icon import as_icon
114
+
115
+ icon = as_icon(icon, color=color)
116
+ if kind is None:
117
+ if icon is not None:
118
+ kind = "icon"
119
+ elif mark is not None:
120
+ kind = "mark"
121
+ elif head is not None or tail is not None:
122
+ kind = "arrow"
123
+ elif style is not None and fill is None and pattern is None:
124
+ kind = "line"
125
+ else:
126
+ kind = "box"
127
+ if kind not in KINDS:
128
+ raise ValueError(f"Key kind must be one of {KINDS}, got {kind!r}")
129
+ self.label = label
130
+ self.kind = kind
131
+ self.fill = fill
132
+ self.stroke = stroke
133
+ self.border = border
134
+ self.pattern = pattern
135
+ self.color = color
136
+ self.style = style
137
+ self.width = width
138
+ self.head = head
139
+ self.tail = tail
140
+ self.hollow = hollow
141
+ self.mark = mark
142
+ self.radius = radius
143
+ self.icon = icon
144
+
145
+ def _sig(self) -> Tuple:
146
+ return (self.label, self.kind, self.fill, self.color, str(self.style), str(self.border),
147
+ type(self.pattern).__name__, str(self.head), getattr(self.icon, "name", None))
148
+
149
+ @classmethod
150
+ def from_node(cls, node: Any, label: str) -> "Key":
151
+ fill = getattr(node, "fill", None)
152
+ stroke = getattr(node, "stroke", None)
153
+ return cls(label, kind="box", fill=fill if fill not in (None, "none") else "#ffffff",
154
+ stroke=stroke, border=getattr(node, "border", None),
155
+ pattern=getattr(node, "pattern", None))
156
+
157
+ @classmethod
158
+ def from_edge(cls, edge: Any, label: str) -> "Key":
159
+ st = edge.resolved()
160
+ head = st.end_head.shape if st.end_head is not None and st.end_head.visible else "none"
161
+ return cls(label, kind="arrow" if head != "none" else "line", color=st.color,
162
+ style=st.dash or st.style, width=st.width, head=head,
163
+ hollow=st.end_head.hollow if st.end_head is not None else None)
164
+
165
+
166
+ def collect_keys(root: Any) -> List[Key]:
167
+ """Keys from every ``legend=`` in ``root``'s tree (nodes, Matrix maps, edges, forks)."""
168
+ from .constraints import walk
169
+ from .edges import Diagram, Edge, Fork
170
+
171
+ keys: List[Key] = []
172
+ seen = set()
173
+
174
+ def add(k: Key) -> None:
175
+ if k.label in seen:
176
+ return
177
+ seen.add(k.label)
178
+ keys.append(k)
179
+
180
+ for n in walk(root):
181
+ if isinstance(n, Legend):
182
+ continue
183
+ if hasattr(n, "legend_entries") and getattr(n, "legend", None):
184
+ for k in n.legend_entries():
185
+ add(k)
186
+ elif isinstance(getattr(n, "legend", None), str) and not isinstance(n, Diagram):
187
+ add(n.legend_key(n.legend) if hasattr(n, "legend_key") else Key.from_node(n, n.legend))
188
+ elif getattr(n, "category", None) is not None and not isinstance(n, Diagram):
189
+ add(Key.from_node(n, str(n.category)))
190
+ if isinstance(n, Diagram):
191
+ for e in n.edges:
192
+ lab = getattr(e, "legend", None)
193
+ if not lab:
194
+ continue
195
+ if isinstance(e, Edge):
196
+ add(Key.from_edge(e, lab))
197
+ elif isinstance(e, Fork):
198
+ probe = Edge(e._node(e.hub), e._node(e.leaves[0]), **e._style_kw())
199
+ add(Key.from_edge(probe, lab))
200
+ order = {"box": 0, "icon": 1, "mark": 1, "dot": 1, "line": 2, "arrow": 2}
201
+ return sorted(keys, key=lambda k: order[k.kind])
202
+
203
+
204
+ CORNERS = ("top-right", "top-left", "bottom-right", "bottom-left")
205
+
206
+
207
+ class Legend(Node):
208
+ """Legend box: swatch + label per key, in ``cols`` columns (or ``direction='row'``).
209
+
210
+ entries: explicit keys (``Key`` or ``(label, fill)`` tuples); ``auto`` (default: only
211
+ when no entries are given; ``auto=True`` to combine) collects ``legend=`` labels and
212
+ ``category=`` names from ``source`` (default: the panel it sits in; a node; or
213
+ ``source="figure"`` for one shared legend over every panel, v0.14.0).
214
+ ``corner`` places the legend inside ``of`` (default: panel content), inset by
215
+ ``inset`` points. ``auto`` chooses a clear corner. ``overlay=True`` excludes
216
+ corner legends from natural sizing; None preserves ordinary layout.
217
+ Swatch size, gaps and padding default to the theme (20×11 px classic).
218
+ """
219
+
220
+ swatch = themed((20.0, 11.0))
221
+ gap = themed(6.0)
222
+ item_gap = themed(12.0)
223
+ stroke_width = themed(0.8, "SW")
224
+ box_pad = themed(5.0)
225
+ row_gap = themed(3.0)
226
+
227
+ def __init__(self, entries: Optional[Sequence[Any]] = None, *, auto: Optional[bool] = None,
228
+ title: Optional[str] = None, cols: Optional[int] = None,
229
+ direction: str = "col", font_size: Optional[float] = None,
230
+ swatch: Optional[Tuple[float, float]] = None, gap: Optional[float] = None,
231
+ item_gap: Optional[float] = None, fill: str = "none",
232
+ stroke: Optional[str] = None, stroke_width: Optional[float] = None,
233
+ pad: Optional[float] = None, source: Any = None,
234
+ corner: Optional[str] = None, of: Any = None,
235
+ inset: float = 6.0, overlay: bool = True,
236
+ **kwargs: Any) -> None:
237
+ if corner not in (None, "auto", *CORNERS):
238
+ raise ValueError(f"Legend corner must be None, auto, {', '.join(CORNERS)}; got {corner!r}")
239
+ self.corner, self.of, self.inset, self.overlay = corner, of, float(inset), overlay
240
+ if direction not in ("col", "row"):
241
+ raise ValueError("Legend direction must be col|row")
242
+ super().__init__(**kwargs)
243
+ self.entries = [self._as_key(e) for e in (entries or [])]
244
+ # auto-collect unless keys were given explicitly (pass auto=True for both)
245
+ self.auto = (entries is None) if auto is None else bool(auto)
246
+ self.title = title
247
+ self.cols = cols
248
+ self.direction = direction
249
+ self.font_size = font_size
250
+ self.swatch = None if swatch is None else (float(swatch[0]), float(swatch[1]))
251
+ self.gap = gap
252
+ self.item_gap = item_gap
253
+ self.fill = fill
254
+ self.stroke = stroke
255
+ self.stroke_width = stroke_width
256
+ self.box_pad = pad
257
+ if isinstance(source, str) and source != "figure":
258
+ raise ValueError('Legend source= must be a node, a list of nodes or "figure"')
259
+ self.source = source
260
+ self._collected: List[Key] = []
261
+
262
+ @staticmethod
263
+ def _as_key(e: Any) -> Key:
264
+ if isinstance(e, Key):
265
+ return e
266
+ if isinstance(e, (tuple, list)) and len(e) == 2:
267
+ return Key(str(e[0]), fill=e[1])
268
+ raise TypeError("Legend entries must be Key or (label, fill) pairs")
269
+
270
+ def collect(self, root: Any) -> None:
271
+ """Gather auto keys from ``source`` (or ``root``); called before measuring."""
272
+ if not self.auto:
273
+ return
274
+ src = self.source if self.source is not None and not isinstance(self.source, str) else root
275
+ if isinstance(src, (list, tuple)):
276
+ keys, seen = [], set()
277
+ for r in src:
278
+ for k in collect_keys(r):
279
+ if k.label not in seen:
280
+ seen.add(k.label)
281
+ keys.append(k)
282
+ else:
283
+ keys = collect_keys(src)
284
+ have = {k.label for k in self.entries}
285
+ new = [k for k in keys if k.label not in have]
286
+ if [k._sig() for k in new] != [k._sig() for k in self._collected]:
287
+ self._collected = new
288
+ self._measured = None
289
+
290
+ def keys(self) -> List[Key]:
291
+ return list(self.entries) + list(self._collected)
292
+
293
+ def _fs(self) -> float:
294
+ return self.font_size if self.font_size is not None else self.theme.font_size * 0.85
295
+
296
+ def _ncols(self) -> int:
297
+ n = max(1, len(self.keys()))
298
+ if self.cols:
299
+ return max(1, min(int(self.cols), n))
300
+ return n if self.direction == "row" else 1
301
+
302
+ def _cells(self) -> Tuple[List[float], float, float]:
303
+ col_w, rows_h, title_h = self._cells_rows()
304
+ return col_w, max(rows_h, default=0.0), title_h
305
+
306
+ def _cells_rows(self) -> Tuple[List[float], List[float], float]:
307
+ """Column widths, per-row heights (v0.13.0: a ``"\n"`` label makes its row
308
+ taller instead of running into the next one) and the title height."""
309
+ fs = self._fs()
310
+ keys = self.keys()
311
+ nc = self._ncols()
312
+ sw, sh = self.swatch
313
+ col_w = [0.0] * nc
314
+ nrows = max(1, (len(keys) + nc - 1) // nc)
315
+ rows_h = [max(sh, fs * 1.25)] * nrows
316
+ for i, k in enumerate(keys):
317
+ tw, _ = measure_text(k.label, fs)
318
+ r, c = divmod(i, nc)
319
+ col_w[c] = max(col_w[c], sw + self.gap + tw)
320
+ n_lines = str(k.label).count("\n") + 1
321
+ rows_h[r] = max(rows_h[r], fs * 1.25 * n_lines)
322
+ title_h = measure_text(self.title, fs, weight="bold")[1] + self.row_gap if self.title else 0.0
323
+ return col_w, rows_h, title_h
324
+
325
+ def _measure(self) -> Tuple[float, float]:
326
+ keys = self.keys()
327
+ col_w, rows_h, title_h = self._cells_rows()
328
+ nc = self._ncols()
329
+ rows = len(rows_h)
330
+ w = sum(col_w) + self.item_gap * (nc - 1)
331
+ if self.title:
332
+ w = max(w, measure_text(self.title, self._fs(), weight="bold")[0])
333
+ h = title_h + sum(rows_h) + (rows - 1) * self.row_gap
334
+ framed = self.fill != "none" or self.stroke not in (None, "none")
335
+ p = self.box_pad if framed else 0.0
336
+ return w + 2 * p, h + 2 * p
337
+
338
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
339
+ from .render_svg import draw_rect, draw_text
340
+
341
+ g = self.geom
342
+ x0, y0 = g.x + ox, g.y + oy
343
+ framed = self.fill != "none" or self.stroke not in (None, "none")
344
+ if framed:
345
+ draw_rect(buf, x0, y0, g.w, g.h, fill=self.fill, stroke=self.stroke or "none",
346
+ stroke_width=self.stroke_width if self.stroke not in (None, "none") else 0,
347
+ radius=self.theme.L(2), dasharray=self._dash(self.stroke_width))
348
+ x0 += self.box_pad
349
+ y0 += self.box_pad
350
+ fs = self._fs()
351
+ col_w, rows_h, title_h = self._cells_rows()
352
+ nc = self._ncols()
353
+ if self.title:
354
+ draw_text(buf, self.title, x0, y0 + fs, font_size=fs, weight="bold")
355
+ sw, sh = self.swatch
356
+ for i, k in enumerate(self.keys()):
357
+ r, c = divmod(i, nc)
358
+ x = x0 + sum(col_w[:c]) + self.item_gap * c
359
+ y = y0 + title_h + sum(rows_h[:r]) + r * self.row_gap
360
+ cy = y + rows_h[r] / 2
361
+ n_lines = str(k.label).count("\n") + 1
362
+ draw_key(buf, k, x, cy - sh / 2, sw, sh, theme=self.theme)
363
+ draw_text(buf, k.label, x + sw + self.gap,
364
+ cy + fs * 0.35 - (n_lines - 1) * fs * 1.25 / 2, font_size=fs)
365
+
366
+
367
+ def draw_key(buf: List[str], k: Key, x: float, y: float, w: float, h: float, *, theme: Any) -> None:
368
+ """Swatch for one key in box (x, y, w, h)."""
369
+ from .primitives import _paint_node_pattern
370
+ from .render_svg import angle_of, draw_circle, draw_head, draw_line, draw_mark, draw_rect
371
+ from .styles import border_dasharray, resolve_edge_style
372
+
373
+ if k.kind == "box":
374
+ stroke = k.stroke
375
+ if stroke is None:
376
+ stroke = "#555555" if k.border not in (None, "none") else theme.stroke
377
+ sw = theme.SW(1.0)
378
+ rad = theme.L(k.radius)
379
+ draw_rect(buf, x, y, w, h, fill=k.fill or "#ffffff", stroke=stroke, stroke_width=sw,
380
+ radius=rad, dasharray=border_dasharray(k.border, sw) if k.border else None)
381
+ if k.pattern is not None:
382
+ _paint_node_pattern(buf, k.pattern, x + 0.5, y + 0.5, w - 1, h - 1)
383
+ draw_rect(buf, x, y, w, h, fill="none", stroke=stroke, stroke_width=sw, radius=rad,
384
+ dasharray=border_dasharray(k.border, sw) if k.border else None)
385
+ elif k.kind == "dot":
386
+ r = min(w, h) / 2
387
+ draw_circle(buf, x + w / 2, y + h / 2, r * 0.8, fill=k.fill or k.color or theme.stroke)
388
+ elif k.kind == "icon":
389
+ s = min(w, h + theme.L(4))
390
+ k.icon.paint(buf, x + (w - s) / 2, y + (h - s) / 2, s, s, color=k.color)
391
+ elif k.kind == "mark":
392
+ draw_mark(buf, k.mark or "check", x + w / 2, y + h / 2, min(w, h), color=k.color or theme.stroke)
393
+ else:
394
+ st = resolve_edge_style(theme, color=k.color, width=k.width, style=k.style,
395
+ head=(k.head if k.kind == "arrow" else "none"), tail=k.tail,
396
+ hollow=k.hollow)
397
+ cy = y + h / 2
398
+ eh = st.end_head
399
+ sh = st.start_head
400
+ x1, x2 = x, x + w
401
+ sb_e = eh.setback(st.width) if eh is not None and eh.visible else 0.0
402
+ sb_s = sh.setback(st.width) if sh is not None and sh.visible else 0.0
403
+ draw_line(buf, x1 + sb_s, cy, x2 - sb_e, cy, stroke=st.color, stroke_width=st.width,
404
+ dasharray=st.dasharray(), opacity=st.opacity)
405
+ draw_head(buf, x2, cy, angle_of(x1, cy, x2, cy), eh, color=st.color, opacity=st.opacity,
406
+ stroke_width=st.width)
407
+ draw_head(buf, x1, cy, angle_of(x2, cy, x1, cy), sh, color=st.color, opacity=st.opacity,
408
+ stroke_width=st.width)
409
+
410
+
411
+ __all__ = ["Hatch", "Key", "Legend", "collect_keys", "draw_key"]
412
+
413
+
414
+ def hatch_pattern(spec: Optional[str]) -> Optional["Hatch"]:
415
+ """Matplotlib-style hatch spec → pattern: ``"////"`` (45°), ``"\\\\"`` (−45°),
416
+ ``"xxxx"`` (cross), ``"----"`` (horizontal), ``"||||"`` (vertical), ``"...."`` (dots)."""
417
+ if not spec:
418
+ return None
419
+ c = spec[0]
420
+ if c == "/":
421
+ return Hatch(angle=45, color="#444444")
422
+ if c == "\\":
423
+ return Hatch(angle=-45, color="#444444")
424
+ if c in "xX+":
425
+ return Hatch(angle=45, cross=True, color="#444444")
426
+ if c == "-":
427
+ return Hatch(angle=0, color="#444444")
428
+ if c == "|":
429
+ return Hatch(angle=90, color="#444444")
430
+ if c in ".o":
431
+ from .primitives import DotGrid
432
+ from .styles import current_theme
433
+
434
+ L = current_theme().L
435
+ return DotGrid(pitch=L(6.0), r=L(0.9), color="#444444", opacity=0.8)
436
+ raise ValueError(f"unknown hatch spec {spec!r}")
437
+
438
+
439
+ def position_corners(panel: Any) -> None:
440
+ """Place corner legends after routing, retaining collision scores for lint."""
441
+ from .constraints import walk
442
+ from .edges import Diagram
443
+ from .figure import LayoutError
444
+ import warnings
445
+
446
+ legends = panel.__dict__.get('_corner_legends', [])
447
+ if not legends:
448
+ return
449
+ nodes = list(walk(panel.content))
450
+ obstacles = []
451
+ for n in nodes:
452
+ if not n.children():
453
+ g = n.geom
454
+ obstacles.append((g.x, g.y, g.w, g.h))
455
+ if isinstance(n, Diagram):
456
+ for _, points in n._routed()[0]:
457
+ for a, b in zip(points, points[1:]):
458
+ obstacles.append((min(a[0], b[0]) - .5, min(a[1], b[1]) - .5,
459
+ abs(a[0]-b[0]) + 1, abs(a[1]-b[1]) + 1))
460
+ for legend in legends:
461
+ g = (legend.of or panel.content).geom
462
+ w, h = legend.measure()
463
+ inset = legend.inset
464
+ if w + 2*inset > g.w or h + 2*inset > g.h:
465
+ raise LayoutError('Legend exceeds reference area; shorten labels, shrink the legend or use external placement')
466
+ candidates = {}
467
+ for corner in CORNERS:
468
+ x = g.x + inset if corner.endswith('left') else g.x + g.w - inset - w
469
+ y = g.y + inset if corner.startswith('top') else g.y + g.h - inset - h
470
+ score = sum(max(0, min(x+w, a+c)-max(x,a))*max(0,min(y+h,b+d)-max(y,b))
471
+ for a,b,c,d in obstacles)
472
+ candidates[corner] = (x,y,score)
473
+ choice = legend.corner
474
+ if choice == 'auto':
475
+ choice = min(CORNERS, key=lambda c: round(candidates[c][2], 8))
476
+ if candidates[choice][2] > 0:
477
+ from .overlaps import OverlapWarning
478
+ warnings.warn('Legend: all corners overlap content; using minimum overlap', OverlapWarning, stacklevel=2)
479
+ x,y,score = candidates[choice]
480
+ legend.layout(x,y,w,h)
481
+ legend._corner_conflict = score
482
+ legend._clear_corners = [c for c in CORNERS if candidates[c][2] == 0 and c != choice]
483
+ obstacles.append((x,y,w,h))