chgksuite 0.26.0b11__py3-none-any.whl → 0.27.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 (53) hide show
  1. chgksuite/_html2md.py +90 -0
  2. chgksuite/cli.py +38 -8
  3. chgksuite/common.py +16 -12
  4. chgksuite/composer/__init__.py +9 -7
  5. chgksuite/composer/chgksuite_parser.py +20 -9
  6. chgksuite/composer/composer_common.py +30 -3
  7. chgksuite/composer/db.py +1 -2
  8. chgksuite/composer/docx.py +542 -292
  9. chgksuite/composer/latex.py +3 -4
  10. chgksuite/composer/lj.py +1 -2
  11. chgksuite/composer/{reddit.py → markdown.py} +35 -25
  12. chgksuite/composer/openquiz.py +2 -3
  13. chgksuite/composer/pptx.py +18 -6
  14. chgksuite/composer/telegram.py +22 -10
  15. chgksuite/handouter/gen.py +11 -7
  16. chgksuite/handouter/installer.py +0 -0
  17. chgksuite/handouter/runner.py +237 -10
  18. chgksuite/handouter/tex_internals.py +12 -13
  19. chgksuite/handouter/utils.py +22 -1
  20. chgksuite/lastdir +1 -0
  21. chgksuite/parser.py +218 -37
  22. chgksuite/parser_db.py +4 -6
  23. chgksuite/resources/labels_az.toml +22 -0
  24. chgksuite/resources/labels_by.toml +1 -2
  25. chgksuite/resources/labels_by_tar.toml +1 -2
  26. chgksuite/resources/labels_en.toml +1 -2
  27. chgksuite/resources/labels_kz_cyr.toml +1 -2
  28. chgksuite/resources/labels_ru.toml +1 -2
  29. chgksuite/resources/labels_sr.toml +1 -2
  30. chgksuite/resources/labels_ua.toml +1 -2
  31. chgksuite/resources/labels_uz.toml +0 -3
  32. chgksuite/resources/labels_uz_cyr.toml +1 -2
  33. chgksuite/resources/regexes_az.json +17 -0
  34. chgksuite/resources/regexes_by.json +3 -2
  35. chgksuite/resources/regexes_by_tar.json +17 -0
  36. chgksuite/resources/regexes_en.json +3 -2
  37. chgksuite/resources/regexes_kz_cyr.json +3 -2
  38. chgksuite/resources/regexes_ru.json +3 -2
  39. chgksuite/resources/regexes_sr.json +3 -2
  40. chgksuite/resources/regexes_ua.json +3 -2
  41. chgksuite/resources/regexes_uz.json +16 -0
  42. chgksuite/resources/regexes_uz_cyr.json +3 -2
  43. chgksuite/trello.py +8 -9
  44. chgksuite/typotools.py +9 -8
  45. chgksuite/version.py +1 -1
  46. {chgksuite-0.26.0b11.dist-info → chgksuite-0.27.0.dist-info}/METADATA +10 -19
  47. chgksuite-0.27.0.dist-info/RECORD +63 -0
  48. {chgksuite-0.26.0b11.dist-info → chgksuite-0.27.0.dist-info}/WHEEL +1 -2
  49. chgksuite/composer/telegram_parser.py +0 -230
  50. chgksuite-0.26.0b11.dist-info/RECORD +0 -59
  51. chgksuite-0.26.0b11.dist-info/top_level.txt +0 -1
  52. {chgksuite-0.26.0b11.dist-info → chgksuite-0.27.0.dist-info}/entry_points.txt +0 -0
  53. {chgksuite-0.26.0b11.dist-info → chgksuite-0.27.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,15 +5,19 @@ import shutil
5
5
  import subprocess
6
6
  import time
7
7
 
8
+ import toml
8
9
  from watchdog.events import FileSystemEventHandler
9
10
  from watchdog.observers import Observer
10
11
 
12
+ from chgksuite.common import get_source_dirs
11
13
  from chgksuite.handouter.gen import generate_handouts
12
14
  from chgksuite.handouter.pack import pack_handouts
13
15
  from chgksuite.handouter.installer import get_tectonic_path, install_tectonic
14
16
  from chgksuite.handouter.tex_internals import (
17
+ EDGE_DASHED,
18
+ EDGE_NONE,
19
+ EDGE_SOLID,
15
20
  GREYTEXT,
16
- GREYTEXT_LANGS,
17
21
  HEADER,
18
22
  IMG,
19
23
  IMGWIDTH,
@@ -29,6 +33,10 @@ class HandoutGenerator:
29
33
 
30
34
  def __init__(self, args):
31
35
  self.args = args
36
+ _, resourcedir = get_source_dirs()
37
+ self.labels = toml.loads(
38
+ read_file(os.path.join(resourcedir, f"labels_{args.language}.toml"))
39
+ )
32
40
  self.blocks = [self.get_header()]
33
41
 
34
42
  def get_header(self):
@@ -51,11 +59,33 @@ class HandoutGenerator:
51
59
  return parse_handouts(contents)
52
60
 
53
61
  def generate_for_question(self, question_num):
54
- return GREYTEXT.replace(
55
- "<GREYTEXT>", GREYTEXT_LANGS[self.args.lang].format(question_num)
62
+ handout_text = self.labels["general"]["handout_for_question"].format(
63
+ question_num
56
64
  )
65
+ return GREYTEXT.replace("<GREYTEXT>", handout_text)
66
+
67
+ def make_tikzbox(self, block, edges=None, ext=None):
68
+ """
69
+ Create a TikZ box with configurable edge styles and extensions.
70
+ edges is a dict with keys 'top', 'bottom', 'left', 'right'
71
+ values are EDGE_DASHED or EDGE_SOLID
72
+ ext is a dict with edge extensions to close gaps at boundaries
73
+ """
74
+ if edges is None:
75
+ edges = {
76
+ "top": EDGE_DASHED,
77
+ "bottom": EDGE_DASHED,
78
+ "left": EDGE_DASHED,
79
+ "right": EDGE_DASHED,
80
+ }
81
+ if ext is None:
82
+ ext = {
83
+ "top": ("0pt", "0pt"),
84
+ "bottom": ("0pt", "0pt"),
85
+ "left": ("0pt", "0pt"),
86
+ "right": ("0pt", "0pt"),
87
+ }
57
88
 
58
- def make_tikzbox(self, block):
59
89
  if block.get("no_center"):
60
90
  align = ""
61
91
  else:
@@ -73,19 +103,206 @@ class HandoutGenerator:
73
103
  .replace("<ALIGN>", align)
74
104
  .replace("<TEXTWIDTH>", textwidth)
75
105
  .replace("<FONTSIZE>", fontsize)
106
+ .replace("<TOP>", edges["top"])
107
+ .replace("<BOTTOM>", edges["bottom"])
108
+ .replace("<LEFT>", edges["left"])
109
+ .replace("<RIGHT>", edges["right"])
110
+ .replace("<TOP_EXT_L>", ext["top"][0])
111
+ .replace("<TOP_EXT_R>", ext["top"][1])
112
+ .replace("<BOTTOM_EXT_L>", ext["bottom"][0])
113
+ .replace("<BOTTOM_EXT_R>", ext["bottom"][1])
114
+ .replace("<LEFT_EXT_T>", ext["left"][0])
115
+ .replace("<LEFT_EXT_B>", ext["left"][1])
116
+ .replace("<RIGHT_EXT_T>", ext["right"][0])
117
+ .replace("<RIGHT_EXT_B>", ext["right"][1])
76
118
  )
77
119
 
78
120
  def get_page_width(self):
79
121
  return self.args.paperwidth - self.args.margin_left - self.args.margin_right - 2
80
122
 
123
+ def get_cut_direction(self, columns, num_rows, handouts_per_team):
124
+ """
125
+ Determine whether to cut vertically or horizontally.
126
+ Returns (direction, team_size) where:
127
+ - direction is 'vertical', 'horizontal', or None
128
+ - team_size is the number of columns (vertical) or rows (horizontal) per team
129
+
130
+ Falls back to None if handouts can't be evenly divided into teams.
131
+ """
132
+ total = columns * num_rows
133
+
134
+ # Check if total handouts can be evenly divided
135
+ if total % handouts_per_team != 0:
136
+ return None, None
137
+
138
+ num_teams = total // handouts_per_team
139
+ if num_teams < 2:
140
+ return None, None # Only 1 team, no cuts needed
141
+
142
+ # Try vertical layout (teams as column groups)
143
+ # Each team gets team_cols columns × all rows
144
+ if handouts_per_team % num_rows == 0:
145
+ team_cols = handouts_per_team // num_rows
146
+ if columns % team_cols == 0:
147
+ return "vertical", team_cols
148
+
149
+ # Try horizontal layout (teams as row groups)
150
+ # Each team gets all columns × team_rows rows
151
+ if handouts_per_team % columns == 0:
152
+ team_rows = handouts_per_team // columns
153
+ if num_rows % team_rows == 0:
154
+ return "horizontal", team_rows
155
+
156
+ return None, None
157
+
158
+ def get_edge_styles(
159
+ self, row_idx, col_idx, num_rows, columns, cut_direction, team_size
160
+ ):
161
+ """
162
+ Determine edge styles and extensions for a box at position (row_idx, col_idx).
163
+ Outer edges of team rectangles are solid (thicker), inner edges are dashed.
164
+ Extensions are used to close gaps in ALL solid lines.
165
+ Duplicate dashed edges are skipped to avoid double lines.
166
+
167
+ team_size is the number of columns (vertical) or rows (horizontal) per team.
168
+ """
169
+ # Default: all dashed, no extension
170
+ edges = {
171
+ "top": EDGE_DASHED,
172
+ "bottom": EDGE_DASHED,
173
+ "left": EDGE_DASHED,
174
+ "right": EDGE_DASHED,
175
+ }
176
+ ext = {
177
+ "top": ("0pt", "0pt"),
178
+ "bottom": ("0pt", "0pt"),
179
+ "left": ("0pt", "0pt"),
180
+ "right": ("0pt", "0pt"),
181
+ }
182
+
183
+ # Gap sizes (half of spacing to extend into)
184
+ h_gap = "0.75mm" # half of SPACE (1.5mm)
185
+ v_gap = "0.5mm" # half of vspace (1mm)
186
+
187
+ # Helper functions to check if position is at a team boundary
188
+ def is_at_right_team_boundary():
189
+ """Is this box at the right edge of its team (but not at grid edge)?"""
190
+ if cut_direction != "vertical" or not team_size:
191
+ return False
192
+ return (col_idx + 1) % team_size == 0 and col_idx < columns - 1
193
+
194
+ def is_at_left_team_boundary():
195
+ """Is this box at the left edge of its team (but not at grid edge)?"""
196
+ if cut_direction != "vertical" or not team_size:
197
+ return False
198
+ return col_idx % team_size == 0 and col_idx > 0
199
+
200
+ def is_at_bottom_team_boundary():
201
+ """Is this box at the bottom edge of its team (but not at grid edge)?"""
202
+ if cut_direction != "horizontal" or not team_size:
203
+ return False
204
+ return (row_idx + 1) % team_size == 0 and row_idx < num_rows - 1
205
+
206
+ def is_at_top_team_boundary():
207
+ """Is this box at the top edge of its team (but not at grid edge)?"""
208
+ if cut_direction != "horizontal" or not team_size:
209
+ return False
210
+ return row_idx % team_size == 0 and row_idx > 0
211
+
212
+ # Determine which edges are solid
213
+ # Only apply solid edges if we have a valid cut direction
214
+ # Otherwise fall back to all-dashed (default)
215
+ if cut_direction is not None:
216
+ # Outer edges of the entire grid
217
+ if row_idx == 0:
218
+ edges["top"] = EDGE_SOLID
219
+ if row_idx == num_rows - 1:
220
+ edges["bottom"] = EDGE_SOLID
221
+ if col_idx == 0:
222
+ edges["left"] = EDGE_SOLID
223
+ if col_idx == columns - 1:
224
+ edges["right"] = EDGE_SOLID
225
+
226
+ # Team boundary edges
227
+ if is_at_right_team_boundary():
228
+ edges["right"] = EDGE_SOLID
229
+ if is_at_left_team_boundary():
230
+ edges["left"] = EDGE_SOLID
231
+ if is_at_bottom_team_boundary():
232
+ edges["bottom"] = EDGE_SOLID
233
+ if is_at_top_team_boundary():
234
+ edges["top"] = EDGE_SOLID
235
+
236
+ # Skip duplicate dashed edges (to avoid double lines between adjacent boxes)
237
+ if edges["left"] == EDGE_DASHED and col_idx > 0:
238
+ edges["left"] = EDGE_NONE
239
+
240
+ if edges["top"] == EDGE_DASHED and row_idx > 0:
241
+ edges["top"] = EDGE_NONE
242
+
243
+ # Calculate extensions for solid edges to close gaps
244
+ # But don't extend into team boundary gaps!
245
+
246
+ if edges["top"] == EDGE_SOLID:
247
+ at_left_boundary = is_at_left_team_boundary()
248
+ ext_left = "-" + h_gap if col_idx > 0 and not at_left_boundary else "0pt"
249
+ at_right_boundary = is_at_right_team_boundary()
250
+ ext_right = (
251
+ h_gap if col_idx < columns - 1 and not at_right_boundary else "0pt"
252
+ )
253
+ ext["top"] = (ext_left, ext_right)
254
+
255
+ if edges["bottom"] == EDGE_SOLID:
256
+ at_left_boundary = is_at_left_team_boundary()
257
+ ext_left = "-" + h_gap if col_idx > 0 and not at_left_boundary else "0pt"
258
+ at_right_boundary = is_at_right_team_boundary()
259
+ ext_right = (
260
+ h_gap if col_idx < columns - 1 and not at_right_boundary else "0pt"
261
+ )
262
+ ext["bottom"] = (ext_left, ext_right)
263
+
264
+ if edges["left"] == EDGE_SOLID:
265
+ at_top_boundary = is_at_top_team_boundary()
266
+ ext_top = v_gap if row_idx > 0 and not at_top_boundary else "0pt"
267
+ at_bottom_boundary = is_at_bottom_team_boundary()
268
+ ext_bottom = (
269
+ "-" + v_gap
270
+ if row_idx < num_rows - 1 and not at_bottom_boundary
271
+ else "0pt"
272
+ )
273
+ ext["left"] = (ext_top, ext_bottom)
274
+
275
+ if edges["right"] == EDGE_SOLID:
276
+ at_top_boundary = is_at_top_team_boundary()
277
+ ext_top = v_gap if row_idx > 0 and not at_top_boundary else "0pt"
278
+ at_bottom_boundary = is_at_bottom_team_boundary()
279
+ ext_bottom = (
280
+ "-" + v_gap
281
+ if row_idx < num_rows - 1 and not at_bottom_boundary
282
+ else "0pt"
283
+ )
284
+ ext["right"] = (ext_top, ext_bottom)
285
+
286
+ return edges, ext
287
+
81
288
  def generate_regular_block(self, block_):
82
289
  block = block_.copy()
83
290
  if not (block.get("image") or block.get("text")):
84
291
  return
85
292
  columns = block["columns"]
86
- spaces = block["columns"] - 1
293
+ num_rows = block.get("rows") or 1
294
+ handouts_per_team = block.get("handouts_per_team") or 3
295
+
296
+ # Determine cut direction
297
+ cut_direction, cut_after = self.get_cut_direction(
298
+ columns, num_rows, handouts_per_team
299
+ )
300
+ if self.args.debug:
301
+ print(f"cut_direction: {cut_direction}, cut_after: {cut_after}")
302
+
303
+ spaces = columns - 1
87
304
  boxwidth = self.args.boxwidth or round(
88
- (self.get_page_width() - spaces * self.SPACE) / block["columns"],
305
+ (self.get_page_width() - spaces * self.SPACE) / columns,
89
306
  3,
90
307
  )
91
308
  total_width = boxwidth * columns + spaces * self.SPACE
@@ -98,7 +315,6 @@ class HandoutGenerator:
98
315
  r"\setlength{\boxwidth}{<Q>mm}%".replace("<Q>", str(boxwidth)),
99
316
  r"\setlength{\boxwidthinner}{<Q>mm}%".replace("<Q>", str(boxwidthinner)),
100
317
  ]
101
- rows = []
102
318
  contents = []
103
319
  if block.get("image"):
104
320
  img_qwidth = block.get("resize_image") or 1.0
@@ -113,10 +329,18 @@ class HandoutGenerator:
113
329
  block["centering"] = ""
114
330
  else:
115
331
  block["centering"] = "\\centering"
116
- for _ in range(block.get("rows") or 1):
332
+
333
+ rows = []
334
+ for row_idx in range(num_rows):
335
+ row_boxes = []
336
+ for col_idx in range(columns):
337
+ edges, ext = self.get_edge_styles(
338
+ row_idx, col_idx, num_rows, columns, cut_direction, cut_after
339
+ )
340
+ row_boxes.append(self.make_tikzbox(block, edges, ext))
117
341
  row = (
118
342
  TIKZBOX_START.replace("<CENTERING>", block["centering"])
119
- + "\n".join([self.make_tikzbox(block)] * block["columns"])
343
+ + "\n".join(row_boxes)
120
344
  + TIKZBOX_END
121
345
  )
122
346
  rows.append(row)
@@ -124,6 +348,9 @@ class HandoutGenerator:
124
348
 
125
349
  def generate(self):
126
350
  for block in self.parse_input(self.args.filename):
351
+ if not block:
352
+ self.blocks.append("\n\\clearpage\n")
353
+ continue
127
354
  if self.args.debug:
128
355
  print(block)
129
356
  if block.get("for_question"):
@@ -138,7 +365,7 @@ class HandoutGenerator:
138
365
 
139
366
  def process_file(args, file_dir, bn):
140
367
  tex_contents = HandoutGenerator(args).generate()
141
- tex_path = os.path.join(file_dir, f"{bn}_{args.lang}.tex")
368
+ tex_path = os.path.join(file_dir, f"{bn}_{args.language}.tex")
142
369
  write_file(tex_path, tex_contents)
143
370
 
144
371
  tectonic_path = get_tectonic_path()
@@ -13,33 +13,32 @@ HEADER = r"""
13
13
  \begin{document}
14
14
  \fontsize{14pt}{16pt}\selectfont
15
15
  \setlength\parindent{0pt}
16
- \tikzstyle{box}=[draw, dashed, rectangle, inner sep=<TIKZ_MM>mm]
16
+ \tikzstyle{box}=[rectangle, inner sep=<TIKZ_MM>mm]
17
17
  \raggedright
18
18
  \raggedbottom
19
19
  """.strip()
20
20
 
21
21
  GREYTEXT = r"""{\fontsize{9pt}{11pt}\selectfont \textcolor{gray}{<GREYTEXT>}}"""
22
22
 
23
- GREYTEXT_LANGS = {
24
- "by": "Да пытаньня {}",
25
- "en": "Handout for question {}",
26
- "kz": "{}-сұрақтың үлестіру материалы",
27
- "ro": "Material care urmează a fi distribuit pentru întrebarea {}",
28
- "ru": "К вопросу {}",
29
- "sr": "Materijal za deljenje uz pitanje {}",
30
- "ua": "До запитання {}",
31
- "uz": "{} саволга тарқатма материал",
32
- }
33
-
34
23
  TIKZBOX_START = r"""{<CENTERING>
35
24
  """
36
25
 
37
26
  TIKZBOX_INNER = r"""
38
27
  \begin{tikzpicture}
39
- \node[box, minimum width=\boxwidth<TEXTWIDTH><ALIGN>] {<FONTSIZE><CONTENTS>\par};
28
+ \node[box, minimum width=\boxwidth<TEXTWIDTH><ALIGN>] (b) {<FONTSIZE><CONTENTS>\par};
29
+ \useasboundingbox (b.south west) rectangle (b.north east);
30
+ \draw[<TOP>] ([xshift=<TOP_EXT_L>]b.north west) -- ([xshift=<TOP_EXT_R>]b.north east);
31
+ \draw[<BOTTOM>] ([xshift=<BOTTOM_EXT_L>]b.south west) -- ([xshift=<BOTTOM_EXT_R>]b.south east);
32
+ \draw[<LEFT>] ([yshift=<LEFT_EXT_T>]b.north west) -- ([yshift=<LEFT_EXT_B>]b.south west);
33
+ \draw[<RIGHT>] ([yshift=<RIGHT_EXT_T>]b.north east) -- ([yshift=<RIGHT_EXT_B>]b.south east);
40
34
  \end{tikzpicture}
41
35
  """.strip()
42
36
 
37
+ # Line styles for box edges
38
+ EDGE_SOLID = "line width=0.8pt"
39
+ EDGE_DASHED = "dashed"
40
+ EDGE_NONE = "draw=none" # Don't draw this edge (to avoid double dashed lines)
41
+
43
42
  TIKZBOX_END = "\n}"
44
43
 
45
44
  IMG = r"""\includegraphics<IMGWIDTH>{<IMGPATH>}"""
@@ -45,8 +45,29 @@ def wrap_val(key, val):
45
45
  return val.strip()
46
46
 
47
47
 
48
+ def split_array_by_value(arr, delimiter):
49
+ result = []
50
+ current_subarray = []
51
+ for item in arr:
52
+ if item == delimiter:
53
+ result.append(current_subarray)
54
+ current_subarray = []
55
+ else:
56
+ current_subarray.append(item)
57
+ result.append(current_subarray)
58
+ return result
59
+
60
+
61
+ def split_blocks(contents):
62
+ lines = contents.split("\n")
63
+ sp = ["\n".join(x) for x in split_array_by_value(lines, "---")]
64
+ if not sp[0].strip():
65
+ sp = sp[1:]
66
+ return sp
67
+
68
+
48
69
  def parse_handouts(contents):
49
- blocks = contents.split("\n---\n")
70
+ blocks = split_blocks(contents)
50
71
  result = []
51
72
  for block_ in blocks:
52
73
  block = block_.strip()
chgksuite/lastdir ADDED
@@ -0,0 +1 @@
1
+ /Users/pecheny/chgksuite1/tmpz_2mf3o8/tmptke0lqfv