|
97 | 97 | edge_renamed="#D68C4A", |
98 | 98 | edge_alpha="C0", |
99 | 99 | schema_cluster=("#606875", "#8A93A1"), |
100 | | - entity_fill="#1E222B", |
| 100 | + entity_fill="#2A313D", |
101 | 101 | ), |
102 | 102 | } |
103 | 103 |
|
104 | 104 |
|
| 105 | +def _adaptive_style_block() -> str: |
| 106 | + """ |
| 107 | + Build a ``<style>`` block that adapts a light-rendered diagram SVG to the |
| 108 | + viewer's dark mode via ``@media (prefers-color-scheme: dark)`` (#1532). |
| 109 | +
|
| 110 | + The two palettes are collision-free — every light color maps to exactly one |
| 111 | + role — so a per-color attribute-selector override is unambiguous. Graphviz |
| 112 | + emits lowercase hex, so selectors are lowercased. |
| 113 | + """ |
| 114 | + light, dark = _DIAGRAM_THEMES["light"], _DIAGRAM_THEMES["dark"] |
| 115 | + dark_edge_opacity = round(int(dark["edge_alpha"], 16) / 255, 3) |
| 116 | + rules = [ |
| 117 | + f"svg {{ background-color: {dark['bg']}; }}", |
| 118 | + f'polygon[fill="white"] {{ fill: {dark["bg"]}; }}', # graph canvas |
| 119 | + ] |
| 120 | + |
| 121 | + def fill_rule(light_hex, dark_hex): |
| 122 | + return f'[fill="{light_hex.lower()}"] {{ fill: {dark_hex}; }}' |
| 123 | + |
| 124 | + def stroke_rule(light_hex, dark_hex, opacity=None): |
| 125 | + extra = f" stroke-opacity: {opacity};" if opacity is not None else "" |
| 126 | + return f'[stroke="{light_hex.lower()}"] {{ stroke: {dark_hex};{extra} }}' |
| 127 | + |
| 128 | + for tier, (lf, ls, lt) in light["palette"].items(): |
| 129 | + df, ds, dt = dark["palette"][tier] |
| 130 | + rules += [fill_rule(lf, df), stroke_rule(ls, ds), fill_rule(lt, dt)] |
| 131 | + # edges (share the theme alpha) |
| 132 | + rules += [ |
| 133 | + stroke_rule(light["edge"], dark["edge"], dark_edge_opacity), |
| 134 | + stroke_rule(light["edge_renamed"], dark["edge_renamed"], dark_edge_opacity), |
| 135 | + ] |
| 136 | + # entity cluster fill + (borderless) stroke, and schema cluster border/label |
| 137 | + rules += [ |
| 138 | + fill_rule(light["entity_fill"], dark["entity_fill"]), |
| 139 | + stroke_rule(light["entity_fill"], dark["entity_fill"]), |
| 140 | + stroke_rule(light["schema_cluster"][0], dark["schema_cluster"][0]), |
| 141 | + fill_rule(light["schema_cluster"][1], dark["schema_cluster"][1]), |
| 142 | + ] |
| 143 | + body = "\n".join(" " + r for r in rules) |
| 144 | + return "<style>\n@media (prefers-color-scheme: dark) {\n" + body + "\n}\n</style>" |
| 145 | + |
| 146 | + |
105 | 147 | class Diagram(nx.MultiDiGraph): # noqa: C901 |
106 | 148 | """ |
107 | 149 | Schema diagram as a directed acyclic graph (DAG). |
@@ -1396,7 +1438,7 @@ def _encapsulate_node_names(graph: nx.MultiDiGraph) -> None: |
1396 | 1438 | copy=False, |
1397 | 1439 | ) |
1398 | 1440 |
|
1399 | | - def make_dot(self): |
| 1441 | + def make_dot(self, theme=None): |
1400 | 1442 | """ |
1401 | 1443 | Generate a pydot graph object. |
1402 | 1444 |
|
@@ -1468,9 +1510,12 @@ def make_dot(self): |
1468 | 1510 | schema_map[node] = data["schema_name"] |
1469 | 1511 |
|
1470 | 1512 | # Select the color theme (#1532). Structure (shape/size/rounded) is |
1471 | | - # theme-independent; only the fill/stroke/text colors change. |
1472 | | - theme_name = self._connection._config.display.diagram_theme |
1473 | | - theme = _DIAGRAM_THEMES.get(theme_name, _DIAGRAM_THEMES["light"]) |
| 1513 | + # theme-independent; only the fill/stroke/text colors change. `theme` |
| 1514 | + # (an explicit argument) overrides the configured default; "auto" is |
| 1515 | + # rendered in light colors here and made adaptive at the SVG layer, so |
| 1516 | + # it maps to the light palette. |
| 1517 | + theme_name = theme or self._connection._config.display.diagram_theme |
| 1518 | + theme = _DIAGRAM_THEMES.get(theme_name if theme_name != "auto" else "light", _DIAGRAM_THEMES["light"]) |
1474 | 1519 | palette = theme["palette"] |
1475 | 1520 |
|
1476 | 1521 | # Build node_props by merging the structural attributes for each tier |
@@ -1665,10 +1710,26 @@ def make_dot(self): |
1665 | 1710 |
|
1666 | 1711 | return dot |
1667 | 1712 |
|
| 1713 | + def svg_string(self) -> str: |
| 1714 | + """ |
| 1715 | + Render the diagram to an SVG string, honoring the color theme. |
| 1716 | +
|
| 1717 | + For ``theme="auto"`` the diagram is rendered in light colors and a |
| 1718 | + ``prefers-color-scheme`` style block is injected so a single image |
| 1719 | + adapts to the viewer's light or dark mode. Other themes render directly. |
| 1720 | + """ |
| 1721 | + theme_name = self._connection._config.display.diagram_theme |
| 1722 | + if theme_name == "auto": |
| 1723 | + svg = self.make_dot(theme="light").create_svg().decode() |
| 1724 | + # Insert the adaptive style block right after the opening <svg ...>. |
| 1725 | + insert_at = svg.find(">", svg.find("<svg")) + 1 |
| 1726 | + return svg[:insert_at] + "\n" + _adaptive_style_block() + svg[insert_at:] |
| 1727 | + return self.make_dot().create_svg().decode() |
| 1728 | + |
1668 | 1729 | def make_svg(self): |
1669 | 1730 | from IPython.display import SVG |
1670 | 1731 |
|
1671 | | - return SVG(self.make_dot().create_svg()) |
| 1732 | + return SVG(self.svg_string()) |
1672 | 1733 |
|
1673 | 1734 | def make_png(self): |
1674 | 1735 | return io.BytesIO(self.make_dot().create_png()) |
|
0 commit comments