QuizGenerator 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.
Files changed (44) hide show
  1. QuizGenerator/README.md +5 -0
  2. QuizGenerator/__init__.py +27 -0
  3. QuizGenerator/__main__.py +7 -0
  4. QuizGenerator/canvas/__init__.py +13 -0
  5. QuizGenerator/canvas/canvas_interface.py +622 -0
  6. QuizGenerator/canvas/classes.py +235 -0
  7. QuizGenerator/constants.py +149 -0
  8. QuizGenerator/contentast.py +1809 -0
  9. QuizGenerator/generate.py +362 -0
  10. QuizGenerator/logging.yaml +55 -0
  11. QuizGenerator/misc.py +480 -0
  12. QuizGenerator/mixins.py +539 -0
  13. QuizGenerator/performance.py +202 -0
  14. QuizGenerator/premade_questions/__init__.py +0 -0
  15. QuizGenerator/premade_questions/basic.py +103 -0
  16. QuizGenerator/premade_questions/cst334/__init__.py +1 -0
  17. QuizGenerator/premade_questions/cst334/languages.py +395 -0
  18. QuizGenerator/premade_questions/cst334/math_questions.py +297 -0
  19. QuizGenerator/premade_questions/cst334/memory_questions.py +1398 -0
  20. QuizGenerator/premade_questions/cst334/ostep13_vsfs.py +572 -0
  21. QuizGenerator/premade_questions/cst334/persistence_questions.py +396 -0
  22. QuizGenerator/premade_questions/cst334/process.py +649 -0
  23. QuizGenerator/premade_questions/cst463/__init__.py +0 -0
  24. QuizGenerator/premade_questions/cst463/gradient_descent/__init__.py +3 -0
  25. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_calculation.py +369 -0
  26. QuizGenerator/premade_questions/cst463/gradient_descent/gradient_descent_questions.py +305 -0
  27. QuizGenerator/premade_questions/cst463/gradient_descent/loss_calculations.py +650 -0
  28. QuizGenerator/premade_questions/cst463/gradient_descent/misc.py +73 -0
  29. QuizGenerator/premade_questions/cst463/math_and_data/__init__.py +2 -0
  30. QuizGenerator/premade_questions/cst463/math_and_data/matrix_questions.py +631 -0
  31. QuizGenerator/premade_questions/cst463/math_and_data/vector_questions.py +534 -0
  32. QuizGenerator/premade_questions/cst463/neural-network-basics/__init__.py +6 -0
  33. QuizGenerator/premade_questions/cst463/neural-network-basics/neural_network_questions.py +1264 -0
  34. QuizGenerator/premade_questions/cst463/tensorflow-intro/__init__.py +6 -0
  35. QuizGenerator/premade_questions/cst463/tensorflow-intro/tensorflow_questions.py +936 -0
  36. QuizGenerator/qrcode_generator.py +293 -0
  37. QuizGenerator/question.py +657 -0
  38. QuizGenerator/quiz.py +468 -0
  39. QuizGenerator/typst_utils.py +113 -0
  40. quizgenerator-0.1.0.dist-info/METADATA +263 -0
  41. quizgenerator-0.1.0.dist-info/RECORD +44 -0
  42. quizgenerator-0.1.0.dist-info/WHEEL +4 -0
  43. quizgenerator-0.1.0.dist-info/entry_points.txt +2 -0
  44. quizgenerator-0.1.0.dist-info/licenses/LICENSE +674 -0
QuizGenerator/quiz.py ADDED
@@ -0,0 +1,468 @@
1
+ #!env python
2
+ from __future__ import annotations
3
+
4
+ import collections
5
+ import itertools
6
+ import logging
7
+ import os.path
8
+ import random
9
+ import shutil
10
+ import subprocess
11
+ import tempfile
12
+ from datetime import datetime
13
+ from typing import List, Dict, Optional
14
+
15
+ import yaml
16
+
17
+ from QuizGenerator.misc import OutputFormat
18
+ from QuizGenerator.contentast import ContentAST
19
+ from QuizGenerator.question import Question, QuestionRegistry, QuestionGroup
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+
24
+ class Quiz:
25
+ """
26
+ A quiz object that will build up questions and output them in a range of formats (hopefully)
27
+ It should be that a single quiz object can contain multiples -- essentially it builds up from the questions and then can generate a variety of questions.
28
+ """
29
+
30
+ INTEREST_THRESHOLD = 1.0
31
+
32
+ def __init__(self, name, questions: List[dict|Question], practice, *args, **kwargs):
33
+ self.name = name
34
+ self.questions = questions
35
+ self.instructions = kwargs.get("instructions", "")
36
+
37
+ # Parse description with ContentAST if provided
38
+ raw_description = kwargs.get("description", None)
39
+ if raw_description:
40
+ # Create a ContentAST document from the description text
41
+ desc_doc = ContentAST.Document()
42
+ desc_doc.add_element(ContentAST.Paragraph([raw_description]))
43
+ self.description = desc_doc.render("html")
44
+ else:
45
+ self.description = None
46
+
47
+ self.question_sort_order = None
48
+ self.practice = practice
49
+ self.preserve_order_point_values = set() # Point values that should preserve question order
50
+
51
+ # Plan: right now we just take in questions and then assume they have a score and a "generate" button
52
+
53
+ def __iter__(self):
54
+ def sort_func(q):
55
+ if self.question_sort_order is not None:
56
+ try:
57
+ return (-q.points_value, self.question_sort_order.index(q.topic))
58
+ except ValueError:
59
+ return (-q.points_value, float('inf'))
60
+ return -q.points_value
61
+ return iter(sorted(self.questions, key=sort_func))
62
+
63
+ @classmethod
64
+ def from_yaml(cls, path_to_yaml) -> List[Quiz]:
65
+
66
+ quizes_loaded : List[Quiz] = []
67
+
68
+ with open(path_to_yaml) as fid:
69
+ list_of_exam_dicts = list(yaml.safe_load_all(fid))
70
+
71
+ for exam_dict in list_of_exam_dicts:
72
+ # Load custom question modules if specified (Option 3: Quick-and-dirty approach)
73
+ # Users can add custom question types by importing Python modules in their YAML:
74
+ # custom_modules:
75
+ # - my_custom_questions.scheduling
76
+ # - university_standard_questions
77
+ custom_modules = exam_dict.get("custom_modules", [])
78
+ if custom_modules:
79
+ import importlib
80
+ for module_name in custom_modules:
81
+ try:
82
+ importlib.import_module(module_name)
83
+ log.info(f"Loaded custom question module: {module_name}")
84
+ except ImportError as e:
85
+ log.error(f"Failed to import custom module '{module_name}': {e}")
86
+ raise
87
+
88
+
89
+ # Get general quiz information from the dictionary
90
+ name = exam_dict.get("name", f"Unnamed Exam ({datetime.now().strftime('%a %b %d %I:%M %p')})")
91
+ practice = exam_dict.get("practice", False)
92
+ description = exam_dict.get("description", None)
93
+ sort_order = list(map(lambda t: Question.Topic.from_string(t), exam_dict.get("sort order", [])))
94
+ sort_order = sort_order + list(filter(lambda t: t not in sort_order, Question.Topic))
95
+
96
+ # Load questions from the quiz dictionary
97
+ questions_for_exam = []
98
+ # Track point values where order should be preserved (for layout optimization)
99
+ preserve_order_point_values = set()
100
+
101
+ for question_value, question_definitions in exam_dict["questions"].items():
102
+ # todo: I can also add in "extra credit" and "mix-ins" as other keys to indicate extra credit or questions that can go anywhere
103
+ log.info(f"Parsing {question_value} point questions")
104
+
105
+ # Check for point-value-level config
106
+ point_config = question_definitions.pop("_config", {})
107
+ if point_config.get("preserve_order", False):
108
+ preserve_order_point_values.add(question_value)
109
+ log.info(f" Point value {question_value} will preserve question order")
110
+
111
+ def make_question(q_name, q_data, **kwargs):
112
+ # Build up the kwargs that we're going to pass in
113
+ # todo: this is currently a mess due to legacy things, so before I tell others to use this make it cleaner
114
+ kwargs= {
115
+ "name": q_name,
116
+ "points_value": question_value,
117
+ **q_data.get("kwargs", {}),
118
+ **q_data,
119
+ **kwargs,
120
+ }
121
+
122
+ # If we are passed in a topic then use it, otherwise don't set it which will have it set to a default
123
+ if "topic" in q_data:
124
+ kwargs["topic"] = Question.Topic.from_string(q_data.get("topic", "Misc"))
125
+
126
+ # Add in a default, where if it isn't specified we're going to simply assume it is a text
127
+ question_class = q_data.get("class", "FromText")
128
+
129
+ new_question = QuestionRegistry.create(
130
+ question_class,
131
+ **kwargs
132
+ )
133
+ return new_question
134
+
135
+ for q_name, q_data in question_definitions.items():
136
+ # Set defaults for config
137
+ question_config = {
138
+ "group" : False,
139
+ "num_to_pick" : 1,
140
+ "random_per_student" : False,
141
+ "repeat": 1,
142
+ "topic": "MISC"
143
+ }
144
+
145
+ # Update config if it exists
146
+ question_config.update(
147
+ q_data.get("_config", {})
148
+ )
149
+ q_data.pop("_config", None)
150
+ q_data.pop("pick", None) # todo: don't use this anymore
151
+ q_data.pop("repeat", None) # todo: don't use this anymore
152
+
153
+ # Check if it is a question group
154
+ if question_config["group"]:
155
+
156
+ # todo: Find a way to allow for "num_to_pick" to ensure lack of duplicates when using duplicates.
157
+ # It's probably going to be somewhere in the instantiate and get_attr fields, with "_current_questions"
158
+ # But will require changing how we add concrete questions (but that'll just be everything returns a list
159
+ questions_for_exam.append(
160
+ QuestionGroup(
161
+ questions_in_group=[
162
+ make_question(name, data | {"topic" : question_config["topic"]}) for name, data in q_data.items()
163
+ ],
164
+ pick_once=(not question_config["random_per_student"])
165
+ )
166
+ )
167
+
168
+ else: # Then this is just a single question
169
+ questions_for_exam.extend([
170
+ make_question(
171
+ q_name,
172
+ q_data,
173
+ rng_seed_offset=repeat_number
174
+ )
175
+ for repeat_number in range(question_config["repeat"])
176
+ ])
177
+ log.debug(f"len(questions_for_exam): {len(questions_for_exam)}")
178
+ quiz_from_yaml = cls(name, questions_for_exam, practice, description=description)
179
+ quiz_from_yaml.set_sort_order(sort_order)
180
+ quiz_from_yaml.preserve_order_point_values = preserve_order_point_values
181
+ quizes_loaded.append(quiz_from_yaml)
182
+ return quizes_loaded
183
+
184
+ def _estimate_question_height(self, question, use_typst_measurement=False, **kwargs) -> float:
185
+ """
186
+ Estimate the rendered height of a question for layout optimization.
187
+ Returns height in centimeters.
188
+
189
+ Args:
190
+ question: Question object to measure
191
+ use_typst_measurement: If True, use Typst's layout engine for exact measurement
192
+ **kwargs: Additional arguments passed to question rendering
193
+
194
+ Returns:
195
+ Height in centimeters
196
+ """
197
+ # Try Typst measurement if requested and available
198
+ if use_typst_measurement:
199
+ from QuizGenerator.typst_utils import measure_typst_content, check_typst_available
200
+
201
+ if check_typst_available():
202
+ try:
203
+ # Render question to Typst
204
+ question_ast = question.get_question(**kwargs)
205
+
206
+ # Get just the content body (without the #question wrapper which adds spacing)
207
+ typst_body = question_ast.body.render("typst", **kwargs)
208
+
209
+ # Measure the content
210
+ measured_height = measure_typst_content(typst_body, page_width_cm=18.0)
211
+
212
+ if measured_height is not None:
213
+ # Add base height for question formatting (header, line, etc.) ~1.5cm
214
+ # Plus the spacing parameter
215
+ total_height = 1.5 + measured_height + question.spacing
216
+ log.debug(f"Typst measurement: {question.name} = {total_height:.2f}cm (content: {measured_height:.2f}cm, spacing: {question.spacing}cm)")
217
+ return total_height
218
+ else:
219
+ log.debug(f"Typst measurement failed for {question.name}, falling back to heuristics")
220
+ except Exception as e:
221
+ log.warning(f"Error during Typst measurement: {e}, falling back to heuristics")
222
+ else:
223
+ log.debug("Typst not available, using heuristic estimation")
224
+
225
+ # Fallback: Use heuristic estimation (original implementation)
226
+ # Base height for question header, borders, and minimal content
227
+ # Each question has: horizontal rule, question number line, and minipage wrapper
228
+ base_height = 1.5 # cm
229
+
230
+ # The spacing parameter directly controls \vspace{} in cm
231
+ spacing_height = question.spacing # cm
232
+
233
+ # Estimate content height by rendering to LaTeX and analyzing structure
234
+ question_ast = question.get_question(**kwargs)
235
+ latex_content = question_ast.render("latex")
236
+
237
+ # Count content that adds height (rough estimates in cm)
238
+ content_height = 0.0
239
+
240
+ # Tables add significant height (~0.5cm per row as rough estimate)
241
+ table_count = latex_content.count('\\begin{tabular}')
242
+ content_height += table_count * 3.0 # Assume ~3cm per table on average
243
+
244
+ # Matrices add height
245
+ matrix_count = latex_content.count('\\begin{') - table_count # Rough matrix count
246
+ content_height += matrix_count * 2.0 # ~2cm per matrix
247
+
248
+ # Code blocks (verbatim) add significant height
249
+ verbatim_count = latex_content.count('\\begin{verbatim}')
250
+ content_height += verbatim_count * 4.0 # ~4cm per code block
251
+
252
+ # Count paragraphs and text blocks (very rough estimate)
253
+ # Each ~500 characters of text ≈ 1cm of height
254
+ char_count = len(latex_content)
255
+ content_height += (char_count / 500.0) * 0.5
256
+
257
+ # Total estimated height
258
+ total_height = base_height + spacing_height + content_height
259
+
260
+ return total_height
261
+
262
+ def _optimize_question_order(self, questions, **kwargs) -> List[Question]:
263
+ """
264
+ Optimize question ordering to minimize PDF length while respecting point-value tiers.
265
+ Uses bin-packing heuristics to reorder questions within each point-value group.
266
+ """
267
+ # Group questions by point value
268
+ from collections import defaultdict
269
+ point_groups = defaultdict(list)
270
+
271
+ for question in questions:
272
+ point_groups[question.points_value].append(question)
273
+
274
+ # Track which point values should preserve order (from config)
275
+ preserve_order_for = kwargs.pop('preserve_order_for', set())
276
+
277
+ # For each point group, estimate heights and apply bin-packing optimization
278
+ optimized_questions = []
279
+ is_first_page = True # Track if we're packing the first page
280
+
281
+ log.debug("Optimizing question order for PDF layout...")
282
+
283
+ for points in sorted(point_groups.keys(), reverse=True):
284
+ group = point_groups[points]
285
+
286
+ # Check if this point tier should preserve order
287
+ if points in preserve_order_for:
288
+ # Sort by topic only (preserve original order)
289
+ group.sort(key=lambda q: self.question_sort_order.index(q.topic))
290
+ optimized_questions.extend(group)
291
+ log.debug(f" {points}pt questions: {len(group)} questions (order preserved by config)")
292
+ # After adding preserved-order questions, we're likely past the first page
293
+ is_first_page = False
294
+ continue
295
+
296
+ # If only 1-2 questions, no optimization needed
297
+ if len(group) <= 2:
298
+ # Still sort by topic for consistency
299
+ group.sort(key=lambda q: self.question_sort_order.index(q.topic))
300
+ optimized_questions.extend(group)
301
+ log.debug(f" {points}pt questions: {len(group)} questions (no optimization needed)")
302
+ is_first_page = False
303
+ continue
304
+
305
+ # Estimate height for each question
306
+ question_heights = [(q, self._estimate_question_height(q, **kwargs)) for q in group]
307
+
308
+ # Sort by height descending to identify large and small questions
309
+ question_heights.sort(key=lambda x: x[1], reverse=True)
310
+
311
+ log.debug(f" Question heights for {points}pt questions:")
312
+ for q, h in question_heights:
313
+ log.debug(f" {q.name}: {h:.1f}cm (spacing={q.spacing}cm)")
314
+
315
+ # Calculate page capacity in centimeters
316
+ # A typical A4 page with margins has ~25cm of usable height
317
+ # After accounting for headers and separators, estimate ~22cm per page
318
+ base_page_capacity = 22.0 # cm
319
+
320
+ # First page has header (title + name line) which takes ~3cm
321
+ first_page_capacity = base_page_capacity - 3.0 if is_first_page else base_page_capacity
322
+
323
+ # Better bin-packing strategy: interleave large and small questions
324
+ # Strategy: Start each page with the largest unplaced question, then fill with smaller ones
325
+ bins = []
326
+ placed = [False] * len(question_heights)
327
+
328
+ while not all(placed):
329
+ # Determine capacity for this page
330
+ page_capacity = first_page_capacity if len(bins) == 0 and is_first_page else base_page_capacity
331
+
332
+ # Find the largest unplaced question to start a new page
333
+ new_page = []
334
+ page_height = 0
335
+
336
+ for i, (question, height) in enumerate(question_heights):
337
+ if not placed[i]:
338
+ new_page.append(question)
339
+ page_height = height
340
+ placed[i] = True
341
+ break
342
+
343
+ # Now try to fill the remaining space with smaller questions
344
+ for i, (question, height) in enumerate(question_heights):
345
+ if not placed[i] and page_height + height <= page_capacity:
346
+ new_page.append(question)
347
+ page_height += height
348
+ placed[i] = True
349
+
350
+ bins.append((new_page, page_height))
351
+
352
+ log.debug(f" {points}pt questions: {len(group)} questions packed into {len(bins)} pages")
353
+ for i, (page_questions, height) in enumerate(bins):
354
+ log.debug(f" Page {i+1}: {height:.1f}cm with {len(page_questions)} questions: {[q.name for q in page_questions]}")
355
+
356
+ # Flatten bins back to ordered list
357
+ for bin_contents, _ in bins:
358
+ optimized_questions.extend(bin_contents)
359
+
360
+ # After packing questions, we're no longer on the first page
361
+ is_first_page = False
362
+
363
+ return optimized_questions
364
+
365
+ def get_quiz(self, **kwargs) -> ContentAST.Document:
366
+ quiz = ContentAST.Document(title=self.name)
367
+
368
+ # Extract master RNG seed (if provided) and remove from kwargs
369
+ master_seed = kwargs.pop('rng_seed', None)
370
+
371
+ # Check if optimization is requested (default: True)
372
+ optimize_layout = kwargs.pop('optimize_layout', True)
373
+
374
+ if optimize_layout:
375
+ # Use optimized ordering, passing preserve_order config
376
+ ordered_questions = self._optimize_question_order(
377
+ self.questions,
378
+ preserve_order_for=self.preserve_order_point_values,
379
+ **kwargs
380
+ )
381
+ else:
382
+ # Use simple ordering by point value and topic
383
+ ordered_questions = sorted(
384
+ self.questions,
385
+ key=lambda q: (-q.points_value, self.question_sort_order.index(q.topic))
386
+ )
387
+
388
+ # Generate questions with sequential numbering for QR codes
389
+ # Use master seed to generate unique per-question seeds
390
+ if master_seed is not None:
391
+ # Create RNG from master seed to generate per-question seeds
392
+ master_rng = random.Random(master_seed)
393
+
394
+ for question_number, question in enumerate(ordered_questions, start=1):
395
+ # Generate a unique seed for this question from the master seed
396
+ if master_seed is not None:
397
+ question_seed = master_rng.randint(0, 2**31 - 1)
398
+ question_ast = question.get_question(rng_seed=question_seed, **kwargs)
399
+ else:
400
+ question_ast = question.get_question(**kwargs)
401
+
402
+ # Add question number to the AST for QR code generation
403
+ question_ast.question_number = question_number
404
+ quiz.add_element(question_ast)
405
+
406
+ return quiz
407
+
408
+ def describe(self):
409
+
410
+ # Print out title
411
+ print(f"Title: {self.name}")
412
+ total_points = sum(map(lambda q: q.points_value, self.questions))
413
+ total_questions = len(self.questions)
414
+
415
+ # Print out overall information
416
+ print(f"{total_points} points total, {total_questions} questions")
417
+
418
+ # Print out the per-value information
419
+ points_counter = collections.Counter([q.points_value for q in self.questions])
420
+ for points in sorted(points_counter.keys(), reverse=True):
421
+ print(f"{points_counter.get(points)} x {points}points")
422
+
423
+ # Either get the sort order or default to the order in the enum class
424
+ sort_order = self.question_sort_order
425
+ if sort_order is None:
426
+ sort_order = Question.Topic
427
+
428
+ # Build per-topic information
429
+
430
+ topic_information = {}
431
+ topic_strings = {}
432
+ for topic in sort_order:
433
+ topic_strings = {"name": topic.name}
434
+
435
+ question_count = len(list(map(lambda q: q.points_value, filter(lambda q: q.topic == topic, self.questions))))
436
+ topic_points = sum(map(lambda q: q.points_value, filter(lambda q: q.topic == topic, self.questions)))
437
+
438
+ # If we have questions add in some states, otherwise mark them as empty
439
+ if question_count != 0:
440
+ topic_strings["count_str"] = f"{question_count} questions ({ 100 * question_count / total_questions:0.1f}%)"
441
+ topic_strings["points_str"] = f"{topic_points:2} points ({ 100 * topic_points / total_points:0.1f}%)"
442
+ else:
443
+ topic_strings["count_str"] = "--"
444
+ topic_strings["points_str"] = "--"
445
+
446
+ topic_information[topic] = topic_strings
447
+
448
+
449
+ # Get padding string lengths
450
+ paddings = collections.defaultdict(lambda: 0)
451
+ for field in topic_strings.keys():
452
+ paddings[field] = max(len(information[field]) for information in topic_information.values())
453
+
454
+ # Print out topics information using the padding
455
+ for topic in sort_order:
456
+ topic_strings = topic_information[topic]
457
+ print(f"{topic_strings['name']:{paddings['name']}} : {topic_strings['count_str']:{paddings['count_str']}} : {topic_strings['points_str']:{paddings['points_str']}}")
458
+
459
+ def set_sort_order(self, sort_order):
460
+ self.question_sort_order = sort_order
461
+
462
+ def main():
463
+ pass
464
+
465
+
466
+ if __name__ == "__main__":
467
+ main()
468
+
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Typst Utilities for Question Measurement
4
+
5
+ Provides utilities to measure question heights using Typst's layout engine,
6
+ enabling accurate bin-packing for PDF generation.
7
+ """
8
+
9
+ import json
10
+ import subprocess
11
+ import tempfile
12
+ import textwrap
13
+ from pathlib import Path
14
+ from typing import Optional
15
+ from QuizGenerator.contentast import ContentAST
16
+
17
+ import logging
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ def measure_typst_content(typst_content: str, page_width_cm: float = 18.0) -> Optional[float]:
22
+ """
23
+ Measure the height of Typst content by compiling and querying.
24
+
25
+ Args:
26
+ typst_content: Typst markup to measure
27
+ page_width_cm: Page width for measurement context (default 18cm)
28
+
29
+ Returns:
30
+ Height in cm, or None if measurement fails
31
+ """
32
+
33
+ # Get the Typst header which includes fillline and other helper functions
34
+ typst_header = ContentAST.Document.TYPST_HEADER
35
+
36
+ # Create temporary Typst file with measurement wrapper
37
+ typst_code = textwrap.dedent(f"""
38
+ {typst_header}
39
+
40
+ #set page(width: {page_width_cm}cm, height: auto, margin: 0cm)
41
+
42
+ #let content_to_measure = [{typst_content}]
43
+
44
+ #context {{
45
+ let measured = measure(content_to_measure)
46
+
47
+ [#metadata((
48
+ height_pt: measured.height.pt(),
49
+ height_cm: measured.height.to-absolute().cm(),
50
+ width_pt: measured.width.pt(),
51
+ width_cm: measured.width.to-absolute().cm(),
52
+ )) <measurement>]
53
+ }}
54
+
55
+ // Render the content (required for compilation)
56
+ #content_to_measure
57
+ """
58
+ )
59
+
60
+ # Write to temporary file
61
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.typ', delete=False) as f:
62
+ f.write(typst_code)
63
+ temp_file = Path(f.name)
64
+
65
+ try:
66
+ # Query for measurements
67
+ result = subprocess.run(
68
+ ['typst', 'query', str(temp_file), '<measurement>', '--format', 'json'],
69
+ capture_output=True,
70
+ text=True,
71
+ timeout=10
72
+ )
73
+
74
+ if result.returncode != 0:
75
+ log.warning(f"Typst query failed: {result.stderr}")
76
+ return None
77
+
78
+ # Parse JSON output
79
+ measurements = json.loads(result.stdout)
80
+
81
+ if measurements:
82
+ return measurements[0]['value']['height_cm']
83
+ else:
84
+ log.warning("No measurements found in Typst output")
85
+ return None
86
+
87
+ except subprocess.TimeoutExpired:
88
+ log.warning("Typst measurement timed out")
89
+ return None
90
+ except Exception as e:
91
+ log.warning(f"Failed to measure Typst content: {e}")
92
+ return None
93
+ finally:
94
+ # Clean up temp file
95
+ temp_file.unlink(missing_ok=True)
96
+
97
+
98
+ def check_typst_available() -> bool:
99
+ """
100
+ Check if Typst is available on the system.
101
+
102
+ Returns:
103
+ True if Typst is available, False otherwise
104
+ """
105
+ try:
106
+ result = subprocess.run(
107
+ ['typst', '--version'],
108
+ capture_output=True,
109
+ timeout=5
110
+ )
111
+ return result.returncode == 0
112
+ except (subprocess.TimeoutExpired, FileNotFoundError):
113
+ return False