sofic 0.1.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.
- sofic/__init__.py +185 -0
- sofic/automata/__init__.py +207 -0
- sofic/automata/_config_simulation.py +40 -0
- sofic/automata/active.py +611 -0
- sofic/automata/alergia.py +222 -0
- sofic/automata/algorithms.py +376 -0
- sofic/automata/atomaton.py +58 -0
- sofic/automata/base.py +161 -0
- sofic/automata/buchi.py +23 -0
- sofic/automata/buchi_simulation.py +67 -0
- sofic/automata/canonical_dual.py +18 -0
- sofic/automata/canonical_extraction.py +122 -0
- sofic/automata/dfa.py +85 -0
- sofic/automata/dfasat.py +195 -0
- sofic/automata/edsm.py +219 -0
- sofic/automata/enumeration.py +44 -0
- sofic/automata/icdfa.py +421 -0
- sofic/automata/idfa.py +363 -0
- sofic/automata/languages/__init__.py +39 -0
- sofic/automata/languages/_quotient_utils.py +64 -0
- sofic/automata/languages/atoms.py +31 -0
- sofic/automata/languages/automaton_ops.py +243 -0
- sofic/automata/languages/base.py +67 -0
- sofic/automata/languages/operations.py +78 -0
- sofic/automata/languages/quotients.py +66 -0
- sofic/automata/languages/residuals.py +25 -0
- sofic/automata/learning.py +79 -0
- sofic/automata/nfa.py +39 -0
- sofic/automata/nwa.py +343 -0
- sofic/automata/nwa_simulation.py +56 -0
- sofic/automata/observation.py +40 -0
- sofic/automata/papni.py +301 -0
- sofic/automata/regex.py +128 -0
- sofic/automata/rfsa.py +35 -0
- sofic/automata/rpni.py +193 -0
- sofic/automata/subsequential.py +201 -0
- sofic/automata/transducer_operations.py +350 -0
- sofic/automata/transducer_simulation.py +150 -0
- sofic/automata/transducers.py +365 -0
- sofic/automata/unifilar.py +107 -0
- sofic/automata/vpa.py +1373 -0
- sofic/automata/vpa_simulation.py +53 -0
- sofic/base.py +153 -0
- sofic/core.py +47 -0
- sofic/examples/__init__.py +86 -0
- sofic/examples/epsilon_machines.py +1089 -0
- sofic/examples/processes.py +1491 -0
- sofic/examples/shifts.py +144 -0
- sofic/exceptions.py +33 -0
- sofic/generators/__init__.py +115 -0
- sofic/generators/_word_measures.py +94 -0
- sofic/generators/alternative_complexity.py +104 -0
- sofic/generators/base.py +327 -0
- sofic/generators/bidirectional_construction.py +717 -0
- sofic/generators/bidirectional_epsilon_machine.py +689 -0
- sofic/generators/block_convergence.py +668 -0
- sofic/generators/block_entropy.py +578 -0
- sofic/generators/channel_measures.py +75 -0
- sofic/generators/conversions.py +182 -0
- sofic/generators/directional_flow.py +245 -0
- sofic/generators/edge_emissions.py +36 -0
- sofic/generators/edge_machine.py +178 -0
- sofic/generators/epsilon_construction.py +193 -0
- sofic/generators/epsilon_inference.py +703 -0
- sofic/generators/epsilon_machine.py +557 -0
- sofic/generators/epsilon_transducer.py +168 -0
- sofic/generators/epsilon_transducer_construction.py +185 -0
- sofic/generators/epsilon_transducer_inference.py +499 -0
- sofic/generators/hmm_inference.py +719 -0
- sofic/generators/information_diagram.py +428 -0
- sofic/generators/lumping.py +447 -0
- sofic/generators/markov.py +100 -0
- sofic/generators/mealy.py +156 -0
- sofic/generators/measures.py +257 -0
- sofic/generators/minimal_generative_model.py +821 -0
- sofic/generators/mixed_state.py +250 -0
- sofic/generators/mixed_state_construction.py +163 -0
- sofic/generators/moore.py +75 -0
- sofic/generators/nmachine.py +78 -0
- sofic/generators/nmachine_construction.py +70 -0
- sofic/generators/pfa.py +100 -0
- sofic/generators/prob.py +291 -0
- sofic/generators/process_equivalence.py +207 -0
- sofic/generators/quasi_inference.py +74 -0
- sofic/generators/quasi_realization.py +97 -0
- sofic/generators/reversal.py +66 -0
- sofic/generators/stack_hmm.py +426 -0
- sofic/generators/stack_inference.py +509 -0
- sofic/generators/stationary.py +134 -0
- sofic/generators/stochastic.py +65 -0
- sofic/generators/synchronization.py +407 -0
- sofic/generators/topological_epsilon_enumeration.py +349 -0
- sofic/generators/words.py +226 -0
- sofic/graph.py +135 -0
- sofic/indexing.py +31 -0
- sofic/inference/__init__.py +45 -0
- sofic/inference/bayesian/__init__.py +68 -0
- sofic/inference/bayesian/comparison.py +199 -0
- sofic/inference/bayesian/counts.py +219 -0
- sofic/inference/bayesian/diversity.py +254 -0
- sofic/inference/bayesian/epsilon.py +270 -0
- sofic/inference/bayesian/hdp_hmm.py +340 -0
- sofic/inference/bayesian/markov.py +294 -0
- sofic/inference/bayesian/pymc_backend.py +71 -0
- sofic/inference/bayesian/stack_hmm.py +215 -0
- sofic/inference/model_selection.py +365 -0
- sofic/inference/spectral.py +564 -0
- sofic/operations.py +16 -0
- sofic/properties.py +339 -0
- sofic/serialization.py +450 -0
- sofic/shifts/__init__.py +48 -0
- sofic/shifts/algorithms.py +84 -0
- sofic/shifts/base.py +49 -0
- sofic/shifts/cover_construction.py +76 -0
- sofic/shifts/covers.py +47 -0
- sofic/shifts/dyck_algorithms.py +100 -0
- sofic/shifts/dyck_enumeration.py +275 -0
- sofic/shifts/markov_dyck.py +172 -0
- sofic/shifts/parry_construction.py +82 -0
- sofic/shifts/sft.py +104 -0
- sofic/shifts/sft_construction.py +52 -0
- sofic/shifts/sliding_block_code.py +156 -0
- sofic/shifts/sofic.py +111 -0
- sofic/shifts/sofic_dyck.py +110 -0
- sofic/shifts/sofic_relation.py +64 -0
- sofic/shifts/textile.py +104 -0
- sofic/shifts/tmc.py +46 -0
- sofic/shifts/tmc_construction.py +58 -0
- sofic/shifts/topological_anatomy.py +150 -0
- sofic/states.py +27 -0
- sofic/testing/__init__.py +8 -0
- sofic/testing/strategies.py +154 -0
- sofic/viz/__init__.py +16 -0
- sofic/viz/_context.py +345 -0
- sofic/viz/_edge.py +216 -0
- sofic/viz/_format.py +89 -0
- sofic/viz/_labels.py +34 -0
- sofic/viz/_names.py +17 -0
- sofic/viz/_rational.py +20 -0
- sofic/viz/_tikz_compile.py +177 -0
- sofic/viz/_tikz_format.py +122 -0
- sofic/viz/_tikz_layout.py +218 -0
- sofic/viz/assets/vaucanson.tikz +71 -0
- sofic/viz/graphviz.py +158 -0
- sofic/viz/idiagram.py +350 -0
- sofic/viz/tikz.py +381 -0
- sofic-0.1.0.dist-info/METADATA +444 -0
- sofic-0.1.0.dist-info/RECORD +150 -0
- sofic-0.1.0.dist-info/WHEEL +4 -0
- sofic-0.1.0.dist-info/licenses/LICENSE.txt +29 -0
sofic/viz/tikz.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""TikZ / Vaucanson rendering for sofic state-machine models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from collections.abc import Hashable, Mapping
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from sofic.base import StateMachine
|
|
14
|
+
from sofic.graph import Transition
|
|
15
|
+
from sofic.viz._context import VizContext, tikz_draw_color, viz_context
|
|
16
|
+
from sofic.viz._edge import (
|
|
17
|
+
PART_EMISSION,
|
|
18
|
+
PART_KIND,
|
|
19
|
+
PART_MATCH_TAG,
|
|
20
|
+
PART_MULTIPLICITY,
|
|
21
|
+
PART_OUTPUT,
|
|
22
|
+
PART_PROB,
|
|
23
|
+
PART_QUASIPROB,
|
|
24
|
+
PART_STACK,
|
|
25
|
+
PART_SYMBOL,
|
|
26
|
+
STYLE_DYCK,
|
|
27
|
+
STYLE_EDGE,
|
|
28
|
+
STYLE_PROB_ONLY,
|
|
29
|
+
STYLE_SYMBOL_ONLY,
|
|
30
|
+
STYLE_TMC,
|
|
31
|
+
STYLE_TRANSDUCER,
|
|
32
|
+
STYLE_VPA,
|
|
33
|
+
edge_spec,
|
|
34
|
+
part_value,
|
|
35
|
+
)
|
|
36
|
+
from sofic.viz._names import node_name
|
|
37
|
+
from sofic.viz._tikz_format import (
|
|
38
|
+
format_belief_tikz_node,
|
|
39
|
+
format_edge_latex,
|
|
40
|
+
format_prob_latex,
|
|
41
|
+
format_state_latex,
|
|
42
|
+
format_state_tikz_node,
|
|
43
|
+
format_symbol_latex,
|
|
44
|
+
format_symbol_only_latex,
|
|
45
|
+
format_transducer_edge_latex,
|
|
46
|
+
latex_escape,
|
|
47
|
+
)
|
|
48
|
+
from sofic.viz._tikz_layout import (
|
|
49
|
+
edge_style,
|
|
50
|
+
layout_circle,
|
|
51
|
+
layout_graphviz,
|
|
52
|
+
placement_to_xy,
|
|
53
|
+
plan_loop_styles,
|
|
54
|
+
)
|
|
55
|
+
from sofic.viz.graphviz import _model_for_viz
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _tikz_state_label(context: VizContext, state: Hashable) -> str:
|
|
59
|
+
from sofic.generators.mixed_state import MixedState, pure_state_index
|
|
60
|
+
|
|
61
|
+
if isinstance(state, MixedState):
|
|
62
|
+
if pure_state_index(state) is not None:
|
|
63
|
+
label = context.state_labels.get(state, format_state_latex(state))
|
|
64
|
+
return latex_escape(label)
|
|
65
|
+
return format_belief_tikz_node(state.belief)
|
|
66
|
+
return format_state_tikz_node(state)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _format_dyck_match_tag_latex(tag: str) -> str:
|
|
70
|
+
if tag.startswith("m") and tag[1:].isdigit():
|
|
71
|
+
return rf"m_{{{tag[1:]}}}"
|
|
72
|
+
return latex_escape(tag)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _tikz_edge_label(model: StateMachine, transition: Transition) -> str:
|
|
76
|
+
spec = edge_spec(model, transition)
|
|
77
|
+
style = spec.style
|
|
78
|
+
|
|
79
|
+
if style == STYLE_SYMBOL_ONLY:
|
|
80
|
+
symbol = part_value(spec, PART_SYMBOL)
|
|
81
|
+
return format_symbol_only_latex(symbol) if symbol is not None else ""
|
|
82
|
+
|
|
83
|
+
if style == STYLE_TRANSDUCER:
|
|
84
|
+
symbol = part_value(spec, PART_SYMBOL)
|
|
85
|
+
output = part_value(spec, PART_OUTPUT)
|
|
86
|
+
if symbol is None and output is None:
|
|
87
|
+
return ""
|
|
88
|
+
return format_transducer_edge_latex(symbol, output)
|
|
89
|
+
|
|
90
|
+
if style == STYLE_PROB_ONLY:
|
|
91
|
+
prob = part_value(spec, PART_PROB)
|
|
92
|
+
return rf"${format_prob_latex(prob)}$" if prob is not None else ""
|
|
93
|
+
|
|
94
|
+
if style == STYLE_EDGE:
|
|
95
|
+
label_symbol = part_value(spec, PART_EMISSION, PART_SYMBOL)
|
|
96
|
+
value = part_value(spec, PART_PROB, PART_QUASIPROB)
|
|
97
|
+
if label_symbol is not None and value is not None:
|
|
98
|
+
return format_edge_latex(label_symbol, value)
|
|
99
|
+
if label_symbol is not None:
|
|
100
|
+
return format_symbol_only_latex(label_symbol)
|
|
101
|
+
if value is not None:
|
|
102
|
+
return rf"${format_prob_latex(value)}$"
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
if style == STYLE_VPA:
|
|
106
|
+
parts: list[str] = []
|
|
107
|
+
for part in spec.parts:
|
|
108
|
+
if part.kind == PART_SYMBOL:
|
|
109
|
+
parts.append(format_symbol_latex(part.value))
|
|
110
|
+
elif part.kind == PART_KIND:
|
|
111
|
+
parts.append(latex_escape(str(part.value)))
|
|
112
|
+
elif part.kind == PART_STACK:
|
|
113
|
+
parts.append(rf"\uparrow{format_symbol_latex(part.value)}")
|
|
114
|
+
return "$" + r"\mid".join(parts) + "$" if parts else ""
|
|
115
|
+
|
|
116
|
+
if style == STYLE_DYCK:
|
|
117
|
+
parts = []
|
|
118
|
+
for part in spec.parts:
|
|
119
|
+
if part.kind == PART_SYMBOL:
|
|
120
|
+
parts.append(rf"\Symbol{{{format_symbol_latex(part.value)}}}")
|
|
121
|
+
elif part.kind == PART_KIND:
|
|
122
|
+
parts.append(rf"\mathrm{{{latex_escape(str(part.value))}}}")
|
|
123
|
+
elif part.kind == PART_MATCH_TAG:
|
|
124
|
+
parts.append(_format_dyck_match_tag_latex(part.value))
|
|
125
|
+
return "$" + r"\mid ".join(parts) + "$" if parts else ""
|
|
126
|
+
|
|
127
|
+
if style == STYLE_TMC:
|
|
128
|
+
parts = []
|
|
129
|
+
for part in spec.parts:
|
|
130
|
+
if part.kind == PART_SYMBOL:
|
|
131
|
+
parts.append(format_symbol_latex(part.value))
|
|
132
|
+
elif part.kind == PART_MULTIPLICITY:
|
|
133
|
+
parts.append(latex_escape(f"\\times {part.value}"))
|
|
134
|
+
return "$" + r"\mid".join(parts) + "$" if parts else ""
|
|
135
|
+
|
|
136
|
+
# STYLE_FALLBACK
|
|
137
|
+
label_symbol = part_value(spec, PART_EMISSION, PART_SYMBOL)
|
|
138
|
+
prob = part_value(spec, PART_PROB)
|
|
139
|
+
if label_symbol is not None and prob is not None:
|
|
140
|
+
return format_edge_latex(label_symbol, prob)
|
|
141
|
+
if label_symbol is not None:
|
|
142
|
+
return format_symbol_only_latex(label_symbol)
|
|
143
|
+
if prob is not None:
|
|
144
|
+
return rf"${format_prob_latex(prob)}$"
|
|
145
|
+
return ""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _tikz_display_kwargs(model: StateMachine) -> dict[str, Any]:
|
|
149
|
+
"""Kwargs for notebook / default TikZ rendering."""
|
|
150
|
+
from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
|
|
151
|
+
|
|
152
|
+
if isinstance(model, BidirectionalEpsilonMachine):
|
|
153
|
+
return {"style": "auto", "layout": "graphviz"}
|
|
154
|
+
return {"style": "auto"}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def model_to_tikz(
|
|
158
|
+
model: StateMachine,
|
|
159
|
+
*,
|
|
160
|
+
fragment: bool = True,
|
|
161
|
+
tikzset_filename: str | None = None,
|
|
162
|
+
style: str = "auto",
|
|
163
|
+
layout: str = "circle",
|
|
164
|
+
radius: str = "2cm",
|
|
165
|
+
bend_angle: int | float = 15,
|
|
166
|
+
scale: float = 1,
|
|
167
|
+
positions: Mapping[Hashable, tuple[float, float]] | None = None,
|
|
168
|
+
angles: Mapping[Hashable, float] | None = None,
|
|
169
|
+
rankdir: str | None = None,
|
|
170
|
+
label: str | None = None,
|
|
171
|
+
edge_label_pos: float = 0.5,
|
|
172
|
+
color_by_emission: bool = True,
|
|
173
|
+
) -> str:
|
|
174
|
+
"""Return a Vaucanson-style TikZ picture for ``model``.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
edge_label_pos: Fraction along each edge (0 = source, 1 = target) at
|
|
178
|
+
which to place the edge label. Use ``1/3`` to keep labels clear of
|
|
179
|
+
mid-edge crossings.
|
|
180
|
+
color_by_emission: Colour edges by emission (or input/label symbol).
|
|
181
|
+
Defaults to ``True``. Visibly pushdown / Dyck kind colours take
|
|
182
|
+
precedence.
|
|
183
|
+
"""
|
|
184
|
+
model = _model_for_viz(model)
|
|
185
|
+
context = viz_context(model, style=style, color_by_emission=color_by_emission)
|
|
186
|
+
|
|
187
|
+
if layout == "circle":
|
|
188
|
+
coords = layout_circle(model, radius=radius, positions=positions, angles=angles)
|
|
189
|
+
elif layout == "graphviz":
|
|
190
|
+
coords = layout_graphviz(model, style=style, rankdir=rankdir)
|
|
191
|
+
else:
|
|
192
|
+
raise ValueError(f"unknown layout {layout!r}; expected 'circle' or 'graphviz'")
|
|
193
|
+
|
|
194
|
+
lines: list[str] = []
|
|
195
|
+
if tikzset_filename:
|
|
196
|
+
lines.append(rf"\tikzsetnextfilename{{{latex_escape(tikzset_filename)}}}")
|
|
197
|
+
|
|
198
|
+
picture_options = [
|
|
199
|
+
"style=vaucanson",
|
|
200
|
+
f"bend angle={bend_angle}",
|
|
201
|
+
f"scale={scale}",
|
|
202
|
+
"every node/.style={transform shape}",
|
|
203
|
+
]
|
|
204
|
+
lines.append(r"\begin{tikzpicture}[" + ",\n ".join(picture_options) + "]")
|
|
205
|
+
|
|
206
|
+
from sofic.generators.bidirectional_epsilon_machine import BidirectionalEpsilonMachine
|
|
207
|
+
from sofic.generators.mixed_state import MixedState, pure_state_index
|
|
208
|
+
|
|
209
|
+
for state in sorted(model.states(), key=repr):
|
|
210
|
+
node = node_name(state)
|
|
211
|
+
placement = coords[state]
|
|
212
|
+
node_label = _tikz_state_label(context, state)
|
|
213
|
+
state_opts = ["state"]
|
|
214
|
+
if (
|
|
215
|
+
isinstance(model, BidirectionalEpsilonMachine)
|
|
216
|
+
or isinstance(state, MixedState)
|
|
217
|
+
and pure_state_index(state) is None
|
|
218
|
+
):
|
|
219
|
+
state_opts.append(r"font=\footnotesize")
|
|
220
|
+
if state in context.accepting_states:
|
|
221
|
+
state_opts.append("accepting")
|
|
222
|
+
fillcolor = context.node_fillcolor(state)
|
|
223
|
+
if fillcolor is not None:
|
|
224
|
+
state_opts.append(f"fill={fillcolor}")
|
|
225
|
+
if context.highlight_initial_states and state in context.initial_states:
|
|
226
|
+
if context.show_start_node:
|
|
227
|
+
state_opts.append("initial")
|
|
228
|
+
else:
|
|
229
|
+
# Match Graphviz penwidth=2.5 (vs 1.0) with Vaucanson's line width=2 default.
|
|
230
|
+
state_opts.append("line width=5pt")
|
|
231
|
+
lines.append(f" \\node [{', '.join(state_opts)}] ({node}) {placement} {{{node_label}}};")
|
|
232
|
+
|
|
233
|
+
transitions = list(model.transitions())
|
|
234
|
+
grouped: dict[tuple[Hashable, Hashable], list[Transition]] = defaultdict(list)
|
|
235
|
+
for transition in transitions:
|
|
236
|
+
key = (transition.source, transition.target)
|
|
237
|
+
grouped[key].append(transition)
|
|
238
|
+
|
|
239
|
+
position_xy = {state: placement_to_xy(coords[state]) for state in coords}
|
|
240
|
+
loop_styles = plan_loop_styles(position_xy, grouped)
|
|
241
|
+
|
|
242
|
+
edge_lines: list[str] = []
|
|
243
|
+
for key in sorted(grouped, key=lambda item: (repr(item[0]), repr(item[1]))):
|
|
244
|
+
source, target = key
|
|
245
|
+
group = grouped[key]
|
|
246
|
+
has_reverse = (target, source) in grouped
|
|
247
|
+
for index, transition in enumerate(group):
|
|
248
|
+
style_opts = edge_style(
|
|
249
|
+
source,
|
|
250
|
+
target,
|
|
251
|
+
parallel_index=index,
|
|
252
|
+
total_parallel=len(group),
|
|
253
|
+
has_reverse=has_reverse,
|
|
254
|
+
loop_style=loop_styles.get((source, target, index)),
|
|
255
|
+
)
|
|
256
|
+
color = context.edge_color(transition)
|
|
257
|
+
if color:
|
|
258
|
+
draw = f"draw={tikz_draw_color(color)}"
|
|
259
|
+
style_opts = f"{style_opts}, {draw}" if style_opts else draw
|
|
260
|
+
edge_label = _tikz_edge_label(model, transition)
|
|
261
|
+
source_name = node_name(source)
|
|
262
|
+
target_name = node_name(target)
|
|
263
|
+
opts = f"[{style_opts}]" if style_opts else ""
|
|
264
|
+
if edge_label:
|
|
265
|
+
label_opts = [
|
|
266
|
+
f"pos={edge_label_pos:g}",
|
|
267
|
+
"fill=white",
|
|
268
|
+
"inner sep=1pt",
|
|
269
|
+
"font=\\scriptsize",
|
|
270
|
+
]
|
|
271
|
+
# On reciprocal pairs, park labels on opposite sides of the bend.
|
|
272
|
+
if has_reverse and source != target:
|
|
273
|
+
label_opts.append("auto")
|
|
274
|
+
if source_name > target_name:
|
|
275
|
+
label_opts.append("swap")
|
|
276
|
+
label_part = f" node[{','.join(label_opts)}] {{{edge_label}}}"
|
|
277
|
+
else:
|
|
278
|
+
label_part = ""
|
|
279
|
+
edge_lines.append(f"({source_name}) edge {opts}{label_part} ({target_name})")
|
|
280
|
+
|
|
281
|
+
if edge_lines:
|
|
282
|
+
lines.append(" \\path " + "\n ".join(edge_lines) + ";")
|
|
283
|
+
|
|
284
|
+
lines.append(r"\end{tikzpicture}")
|
|
285
|
+
if label:
|
|
286
|
+
lines.append(rf"\label{{{latex_escape(label)}}}")
|
|
287
|
+
|
|
288
|
+
body = "\n".join(lines) + "\n"
|
|
289
|
+
if fragment:
|
|
290
|
+
return body
|
|
291
|
+
return _standalone_document(body)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _standalone_document(body: str) -> str:
|
|
295
|
+
from sofic.viz._tikz_compile import compilation_document
|
|
296
|
+
|
|
297
|
+
return compilation_document(body)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def compile_tikz(
|
|
301
|
+
fragment: str,
|
|
302
|
+
*,
|
|
303
|
+
format: str = "png",
|
|
304
|
+
) -> bytes:
|
|
305
|
+
"""Compile a TikZ fragment to PDF, PNG, or SVG bytes."""
|
|
306
|
+
from sofic.viz._tikz_compile import compile_tikz_fragment
|
|
307
|
+
|
|
308
|
+
return compile_tikz_fragment(fragment, format=format)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def model_to_tikz_image(
|
|
312
|
+
model: StateMachine,
|
|
313
|
+
*,
|
|
314
|
+
format: str = "png",
|
|
315
|
+
**kwargs: Any,
|
|
316
|
+
) -> bytes:
|
|
317
|
+
"""Render ``model`` to compiled image bytes via ``pdflatex``."""
|
|
318
|
+
display_kwargs = {**_tikz_display_kwargs(model), **kwargs}
|
|
319
|
+
fragment = model_to_tikz(model, fragment=True, **display_kwargs)
|
|
320
|
+
return compile_tikz(fragment, format=format)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def draw_tikz(
|
|
324
|
+
model: StateMachine,
|
|
325
|
+
filename: str | None = None,
|
|
326
|
+
*,
|
|
327
|
+
format: str | None = None,
|
|
328
|
+
view: bool = False,
|
|
329
|
+
**kwargs: Any,
|
|
330
|
+
) -> str | None:
|
|
331
|
+
"""Write a TikZ figure for ``model``.
|
|
332
|
+
|
|
333
|
+
When ``format`` is ``png``, ``svg``, or ``pdf`` (or inferred from ``filename``),
|
|
334
|
+
compile with ``pdflatex`` and write the rendered image. Otherwise write a
|
|
335
|
+
``.tikz`` LaTeX fragment.
|
|
336
|
+
"""
|
|
337
|
+
import subprocess
|
|
338
|
+
import sys
|
|
339
|
+
|
|
340
|
+
inferred = None
|
|
341
|
+
if filename is not None:
|
|
342
|
+
inferred = Path(filename).suffix.lstrip(".").lower() or None
|
|
343
|
+
resolved_format = (format or inferred or "tikz").lower()
|
|
344
|
+
|
|
345
|
+
display_kwargs = {**_tikz_display_kwargs(model), **kwargs}
|
|
346
|
+
fragment = model_to_tikz(model, fragment=True, **display_kwargs)
|
|
347
|
+
if resolved_format == "tikz":
|
|
348
|
+
if filename is None:
|
|
349
|
+
return None
|
|
350
|
+
path = Path(filename)
|
|
351
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
352
|
+
path.write_text(fragment, encoding="utf-8")
|
|
353
|
+
if view:
|
|
354
|
+
subprocess.run(["open", str(path)], check=False)
|
|
355
|
+
return str(path)
|
|
356
|
+
|
|
357
|
+
image_bytes = compile_tikz(fragment, format=resolved_format)
|
|
358
|
+
if filename is None:
|
|
359
|
+
suffix = f".{resolved_format}"
|
|
360
|
+
fd, tmp_name = tempfile.mkstemp(suffix=suffix)
|
|
361
|
+
os.close(fd)
|
|
362
|
+
tmp = Path(tmp_name)
|
|
363
|
+
tmp.write_bytes(image_bytes)
|
|
364
|
+
if view:
|
|
365
|
+
if sys.platform == "darwin":
|
|
366
|
+
subprocess.run(["open", str(tmp)], check=False)
|
|
367
|
+
elif shutil.which("xdg-open"):
|
|
368
|
+
subprocess.run(["xdg-open", str(tmp)], check=False)
|
|
369
|
+
return str(tmp)
|
|
370
|
+
|
|
371
|
+
path = Path(filename)
|
|
372
|
+
if path.suffix == "":
|
|
373
|
+
path = path.with_suffix(f".{resolved_format}")
|
|
374
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
path.write_bytes(image_bytes)
|
|
376
|
+
if view:
|
|
377
|
+
if sys.platform == "darwin":
|
|
378
|
+
subprocess.run(["open", str(path)], check=False)
|
|
379
|
+
elif shutil.which("xdg-open"):
|
|
380
|
+
subprocess.run(["xdg-open", str(path)], check=False)
|
|
381
|
+
return str(path)
|