duqing2026 commited on
Commit
e56ec6a
·
1 Parent(s): 9ed41e7

feat: enhance features with blank mode, answer keys and Chinese font support

Browse files
Files changed (4) hide show
  1. .gitignore +5 -0
  2. Dockerfile +3 -2
  3. app.py +208 -77
  4. templates/index.html +155 -62
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .DS_Store
4
+ .env
5
+ venv/
Dockerfile CHANGED
@@ -1,9 +1,10 @@
1
  FROM python:3.9-slim
2
 
3
- # Install system dependencies (if any)
4
- # We might need fonts for ReportLab if we want better PDF rendering
5
  RUN apt-get update && apt-get install -y \
6
  fonts-liberation \
 
7
  && rm -rf /var/lib/apt/lists/*
8
 
9
  # Create user with ID 1000 for Hugging Face Spaces
 
1
  FROM python:3.9-slim
2
 
3
+ # Install system dependencies
4
+ # fonts-wqy-microhei is a good open source Chinese font
5
  RUN apt-get update && apt-get install -y \
6
  fonts-liberation \
7
+ fonts-wqy-microhei \
8
  && rm -rf /var/lib/apt/lists/*
9
 
10
  # Create user with ID 1000 for Hugging Face Spaces
app.py CHANGED
@@ -10,135 +10,268 @@ from reportlab.lib.units import cm
10
 
11
  app = Flask(__name__)
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  # --- Math Logic ---
14
 
15
- def generate_problem(ops, min_val, max_val, allow_remainder=False, no_negative=True):
 
 
 
 
16
  op = random.choice(ops)
 
 
17
 
 
18
  if op == '+':
19
  a = random.randint(min_val, max_val)
20
  b = random.randint(min_val, max_val)
21
- # Optional: Limit sum? Let's just keep simple range for operands
22
  res = a + b
23
- return f"{a} + {b} =", res
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  elif op == '-':
26
  a = random.randint(min_val, max_val)
27
  b = random.randint(min_val, max_val)
28
  if no_negative and a < b:
29
  a, b = b, a
30
  res = a - b
31
- return f"{a} - {b} =", res
32
 
 
 
 
 
 
 
 
 
 
 
 
33
  elif op == '×':
34
- # For multiplication, usually we want smaller range if max_val is huge,
35
- # but let's assume user sets range appropriately (e.g. 1-9 for times table)
36
  a = random.randint(min_val, max_val)
37
  b = random.randint(min_val, max_val)
38
  res = a * b
39
- return f"{a} × {b} =", res
40
 
 
 
 
 
 
 
 
 
 
 
 
41
  elif op == '÷':
42
  b = random.randint(min_val, max_val)
43
  if b == 0: b = 1
44
 
45
  if allow_remainder:
46
- a = random.randint(min_val, max_val * 10) # arbitrary scaling
47
- res = f"{a//b} ... {a%b}"
48
- if a % b == 0: res = str(a // b)
 
 
 
 
 
 
 
 
 
 
 
49
  else:
50
- # Create perfect division
51
- res = random.randint(min_val, max_val) # This is the answer
52
- a = res * b
53
- # Check if 'a' is too big? Let's assume acceptable.
54
- # Actually, let's limit 'a' by max_val?
55
- # Better strategy:
56
- # If user says range 1-100. They usually mean operands.
57
- # Let's generate factor A and B in range.
58
- a_factor = random.randint(min_val, max_val)
59
- b_factor = random.randint(min_val, max_val)
60
- if b_factor == 0: b_factor = 1
61
- product = a_factor * b_factor
62
- a = product
63
- b = b_factor
64
- res = a_factor
65
 
66
- return f"{a} ÷ {b} =", res
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
- def generate_sheet_data(count, ops, min_val, max_val):
69
  problems = []
70
  for _ in range(count):
71
- p_str, ans = generate_problem(ops, min_val, max_val)
72
- problems.append({"question": p_str, "answer": str(ans)})
73
  return problems
74
 
75
  # --- PDF Logic ---
76
 
77
- def create_pdf(problems, title="口算练习题", with_answers=False):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  buffer = io.BytesIO()
79
  c = canvas.Canvas(buffer, pagesize=A4)
80
  width, height = A4
81
 
82
- # Simple setup - we don't have Chinese fonts by default in standard docker images usually
83
- # But let's try to use standard font or bundled one?
84
- # For now, let's use Helvetica for numbers/ops.
85
- # Title might need Chinese.
86
- # STRATEGY: Use a standard font for math.
87
- # If title is Chinese, we need a font.
88
- # I will bundle a lightweight font or use built-in if possible.
89
- # Actually, let's assume English title fallback or try to register a font if available.
90
- # For HF Spaces, we can download a font.
91
- # Let's keep it simple: Use English Title by default or "Math Worksheet".
92
-
93
- # Wait, requirement is "Chinese Display".
94
- # I should download a font or use a system one.
95
- # In 'app.py', I'll check for a font file.
96
-
97
- font_name = "Helvetica"
98
- # c.setFont(font_name, 12)
99
-
100
- # Layout
101
  margin_x = 2 * cm
102
  margin_y = 2 * cm
103
- cols = 4
104
- rows_per_page = 25 # approx
105
 
 
106
  col_width = (width - 2 * margin_x) / cols
107
- row_height = 1.5 * cm
108
 
109
- y = height - margin_y - 2*cm # Start below title
110
 
111
- # Draw Title
112
- c.setFont("Helvetica-Bold", 24)
113
- c.drawCentredString(width/2, height - margin_y, "Math Worksheet")
114
 
115
- c.setFont("Helvetica", 14)
116
- c.drawString(margin_x, height - margin_y - 1*cm, "Name: _________________ Date: _________________ Score: ________")
117
-
118
- c.setFont("Helvetica", 16)
119
 
120
- x_start = margin_x
 
 
 
 
 
121
 
122
  current_col = 0
123
- current_row = 0
124
 
 
125
  for i, p in enumerate(problems):
126
- x = x_start + current_col * col_width
127
- # y is calculated
128
 
129
- q_text = p['question']
130
- c.drawString(x, y, q_text)
131
 
132
  current_col += 1
133
  if current_col >= cols:
134
  current_col = 0
135
  y -= row_height
136
 
 
137
  if y < margin_y:
138
  c.showPage()
139
- y = height - margin_y - 2*cm
140
- c.setFont("Helvetica", 16)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
 
 
 
 
 
 
 
 
 
 
 
 
142
  c.save()
143
  buffer.seek(0)
144
  return buffer
@@ -156,26 +289,24 @@ def preview():
156
  min_val = int(data.get('min', 1))
157
  max_val = int(data.get('max', 20))
158
  ops = data.get('ops', ['+'])
 
159
 
160
- problems = generate_sheet_data(count, ops, min_val, max_val)
161
  return jsonify(problems)
162
 
163
  @app.route('/api/download', methods=['POST'])
164
  def download():
165
- # In a real app, we might pass params in URL or use a form submit.
166
- # For simplicity, let's use JSON -> generate -> return file?
167
- # Browsers can't download file from fetch(json) easily without blob handling.
168
- # Let's accept query params for GET or handle blob in frontend.
169
- # Let's support POST and return blob.
170
-
171
  data = request.json
172
  count = int(data.get('count', 50))
173
  min_val = int(data.get('min', 1))
174
  max_val = int(data.get('max', 20))
175
  ops = data.get('ops', ['+'])
 
 
 
176
 
177
- problems = generate_sheet_data(count, ops, min_val, max_val)
178
- pdf_buffer = create_pdf(problems)
179
 
180
  return send_file(
181
  pdf_buffer,
 
10
 
11
  app = Flask(__name__)
12
 
13
+ # --- Font Registration ---
14
+ FONT_NAME = "Helvetica" # Default fallback
15
+ CN_FONT_NAME = None
16
+
17
+ def register_fonts():
18
+ global FONT_NAME, CN_FONT_NAME
19
+ # Priority list for Chinese fonts
20
+ font_paths = [
21
+ "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", # Debian/Ubuntu (Docker)
22
+ "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
23
+ "./static/fonts/SimHei.ttf", # Local custom
24
+ "/System/Library/Fonts/STHeiti Light.ttc", # macOS fallback (optional)
25
+ ]
26
+
27
+ for path in font_paths:
28
+ if os.path.exists(path):
29
+ try:
30
+ # Try to register. For TTC, usually need to try index 0
31
+ # If it fails, reportlab might raise error
32
+ # Note: 'wqy-microhei' is usually "WenQuanYi Micro Hei"
33
+ font_key = "ChineseFont"
34
+ pdfmetrics.registerFont(TTFont(font_key, path))
35
+ CN_FONT_NAME = font_key
36
+ print(f"Successfully registered Chinese font: {path}")
37
+ break
38
+ except Exception as e:
39
+ print(f"Failed to register font {path}: {e}")
40
+
41
+ register_fonts()
42
+
43
  # --- Math Logic ---
44
 
45
+ def generate_problem(ops, min_val, max_val, allow_remainder=False, no_negative=True, blank_prob=0):
46
+ """
47
+ Generates a single math problem.
48
+ blank_prob: 0.0 to 1.0, probability of having a blank in operands (e.g. 3 + _ = 5)
49
+ """
50
  op = random.choice(ops)
51
+ question = ""
52
+ answer = ""
53
 
54
+ # 1. Generate Numbers based on Operation
55
  if op == '+':
56
  a = random.randint(min_val, max_val)
57
  b = random.randint(min_val, max_val)
 
58
  res = a + b
 
59
 
60
+ # Format: a + b = res
61
+ # With blank_prob, we might hide a or b
62
+ if random.random() < blank_prob:
63
+ # Hide a or b
64
+ if random.choice([True, False]):
65
+ question = f"( ) + {b} = {res}"
66
+ answer = str(a)
67
+ else:
68
+ question = f"{a} + ( ) = {res}"
69
+ answer = str(b)
70
+ else:
71
+ question = f"{a} + {b} ="
72
+ answer = str(res)
73
+
74
  elif op == '-':
75
  a = random.randint(min_val, max_val)
76
  b = random.randint(min_val, max_val)
77
  if no_negative and a < b:
78
  a, b = b, a
79
  res = a - b
 
80
 
81
+ if random.random() < blank_prob:
82
+ if random.choice([True, False]):
83
+ question = f"( ) - {b} = {res}"
84
+ answer = str(a)
85
+ else:
86
+ question = f"{a} - ( ) = {res}"
87
+ answer = str(b)
88
+ else:
89
+ question = f"{a} - {b} ="
90
+ answer = str(res)
91
+
92
  elif op == '×':
 
 
93
  a = random.randint(min_val, max_val)
94
  b = random.randint(min_val, max_val)
95
  res = a * b
 
96
 
97
+ if random.random() < blank_prob:
98
+ if random.choice([True, False]):
99
+ question = f"( ) × {b} = {res}"
100
+ answer = str(a)
101
+ else:
102
+ question = f"{a} × ( ) = {res}"
103
+ answer = str(b)
104
+ else:
105
+ question = f"{a} × {b} ="
106
+ answer = str(res)
107
+
108
  elif op == '÷':
109
  b = random.randint(min_val, max_val)
110
  if b == 0: b = 1
111
 
112
  if allow_remainder:
113
+ # Division with remainder allowed
114
+ # We construct 'a' such that it might have remainder
115
+ # But usually we want controlled difficulty.
116
+ # Let's pick 'res' (quotient) and 'rem' (remainder)
117
+ quotient = random.randint(min_val, max_val)
118
+ remainder = random.randint(0, b - 1)
119
+ a = quotient * b + remainder
120
+ res = f"{quotient} ... {remainder}" if remainder != 0 else str(quotient)
121
+
122
+ # Blanks in division with remainder is complex, let's skip blanks for remainder mode for simplicity
123
+ # or only hide dividend/divisor if remainder is 0
124
+ question = f"{a} ÷ {b} ="
125
+ answer = str(res)
126
+
127
  else:
128
+ # Exact division
129
+ res_val = random.randint(min_val, max_val)
130
+ a = res_val * b
131
+ res = str(res_val)
 
 
 
 
 
 
 
 
 
 
 
132
 
133
+ if random.random() < blank_prob:
134
+ if random.choice([True, False]):
135
+ question = f"( ) ÷ {b} = {res}"
136
+ answer = str(a)
137
+ else:
138
+ # a / ? = res => ? = a / res
139
+ # Avoid division by zero if res is 0
140
+ if res_val == 0:
141
+ question = f"{a} ÷ {b} =" # Fallback
142
+ answer = str(res)
143
+ else:
144
+ question = f"{a} ÷ ( ) = {res}"
145
+ answer = str(b)
146
+ else:
147
+ question = f"{a} ÷ {b} ="
148
+ answer = str(res)
149
+
150
+ return {"question": question, "answer": answer}
151
 
152
+ def generate_sheet_data(count, ops, min_val, max_val, blank_prob=0):
153
  problems = []
154
  for _ in range(count):
155
+ p = generate_problem(ops, min_val, max_val, blank_prob=blank_prob)
156
+ problems.append(p)
157
  return problems
158
 
159
  # --- PDF Logic ---
160
 
161
+ def draw_header(c, width, height, margin_y, title):
162
+ # Use Chinese font if available, else Helvetica
163
+ title_font = CN_FONT_NAME if CN_FONT_NAME else "Helvetica-Bold"
164
+ text_font = CN_FONT_NAME if CN_FONT_NAME else "Helvetica"
165
+
166
+ # Title
167
+ c.setFont(title_font, 24)
168
+ # If no Chinese font and title contains non-ascii, fallback to English
169
+ display_title = title
170
+ if not CN_FONT_NAME and any(ord(char) > 127 for char in title):
171
+ display_title = "Math Worksheet"
172
+
173
+ c.drawCentredString(width/2, height - margin_y, display_title)
174
+
175
+ # Info Line
176
+ c.setFont(text_font, 12)
177
+ info_y = height - margin_y - 1.2*cm
178
+
179
+ if CN_FONT_NAME:
180
+ c.drawString(2*cm, info_y, "姓名: ______________")
181
+ c.drawString(width/2 - 2*cm, info_y, "日期: ______________")
182
+ c.drawString(width - 5*cm, info_y, "用时: ________")
183
+ c.drawString(width - 2.5*cm, info_y, "分数: ________")
184
+ else:
185
+ c.drawString(2*cm, info_y, "Name: ______________ Date: ______________ Time: ______ Score: ______")
186
+
187
+ def create_pdf(problems, title="口算练习题", with_answers=True, cols=4):
188
  buffer = io.BytesIO()
189
  c = canvas.Canvas(buffer, pagesize=A4)
190
  width, height = A4
191
 
192
+ # Config
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  margin_x = 2 * cm
194
  margin_y = 2 * cm
 
 
195
 
196
+ # Calculate layout
197
  col_width = (width - 2 * margin_x) / cols
198
+ row_height = 1.4 * cm
199
 
200
+ # --- Problem Pages ---
201
 
202
+ # Draw first header
203
+ draw_header(c, width, height, margin_y, title)
 
204
 
205
+ y_start = height - margin_y - 2.5*cm
206
+ y = y_start
 
 
207
 
208
+ # Font for problems - Use Helvetica for numbers/symbols as it looks better usually
209
+ # But if question contains Chinese (not now), we need CN font.
210
+ # Our questions are "1 + 2 =", pure ascii usually.
211
+ # But we might have brackets ( ) which are fine.
212
+ problem_font = "Helvetica"
213
+ c.setFont(problem_font, 14)
214
 
215
  current_col = 0
 
216
 
217
+ # We might need multiple pages for problems
218
  for i, p in enumerate(problems):
219
+ x = margin_x + current_col * col_width
 
220
 
221
+ c.drawString(x, y, p['question'])
 
222
 
223
  current_col += 1
224
  if current_col >= cols:
225
  current_col = 0
226
  y -= row_height
227
 
228
+ # Check page break
229
  if y < margin_y:
230
  c.showPage()
231
+ # New page header (Simplified)
232
+ y = height - margin_y
233
+ c.setFont(problem_font, 14)
234
+
235
+ # End of problems
236
+ c.showPage()
237
+
238
+ # --- Answer Key Page (Optional) ---
239
+ if with_answers:
240
+ # Header
241
+ title_font = CN_FONT_NAME if CN_FONT_NAME else "Helvetica-Bold"
242
+ c.setFont(title_font, 18)
243
+ ans_title = "参考答案" if CN_FONT_NAME else "Answer Key"
244
+ c.drawCentredString(width/2, height - margin_y, ans_title)
245
+
246
+ y = height - margin_y - 1.5*cm
247
+ c.setFont("Helvetica", 11) # Smaller font for answers
248
+
249
+ # Use more cols for answers to save paper
250
+ ans_cols = 5
251
+ ans_col_width = (width - 2 * margin_x) / ans_cols
252
+ ans_row_height = 0.8 * cm
253
+
254
+ current_col = 0
255
+
256
+ for i, p in enumerate(problems):
257
+ x = margin_x + current_col * ans_col_width
258
+
259
+ # Format: "1) 15"
260
+ ans_text = f"{i+1}) {p['answer']}"
261
+ c.drawString(x, y, ans_text)
262
 
263
+ current_col += 1
264
+ if current_col >= ans_cols:
265
+ current_col = 0
266
+ y -= ans_row_height
267
+
268
+ if y < margin_y:
269
+ c.showPage()
270
+ y = height - margin_y
271
+ c.setFont("Helvetica", 11)
272
+
273
+ c.showPage()
274
+
275
  c.save()
276
  buffer.seek(0)
277
  return buffer
 
289
  min_val = int(data.get('min', 1))
290
  max_val = int(data.get('max', 20))
291
  ops = data.get('ops', ['+'])
292
+ blank_prob = float(data.get('blank_prob', 0)) # 0 to 1
293
 
294
+ problems = generate_sheet_data(count, ops, min_val, max_val, blank_prob)
295
  return jsonify(problems)
296
 
297
  @app.route('/api/download', methods=['POST'])
298
  def download():
 
 
 
 
 
 
299
  data = request.json
300
  count = int(data.get('count', 50))
301
  min_val = int(data.get('min', 1))
302
  max_val = int(data.get('max', 20))
303
  ops = data.get('ops', ['+'])
304
+ blank_prob = float(data.get('blank_prob', 0))
305
+ with_answers = data.get('with_answers', False)
306
+ cols = int(data.get('cols', 4))
307
 
308
+ problems = generate_sheet_data(count, ops, min_val, max_val, blank_prob)
309
+ pdf_buffer = create_pdf(problems, with_answers=with_answers, cols=cols)
310
 
311
  return send_file(
312
  pdf_buffer,
templates/index.html CHANGED
@@ -14,91 +14,144 @@
14
  background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
15
  background-size: 20px 20px;
16
  }
 
 
 
17
  </style>
18
  </head>
19
- <body class="bg-gray-100 min-h-screen text-gray-800">
20
 
21
- <div class="container mx-auto px-4 py-8 flex flex-col md:flex-row gap-6 h-screen overflow-hidden">
22
 
23
  <!-- Sidebar: Settings -->
24
- <div class="w-full md:w-1/3 lg:w-1/4 bg-white rounded-2xl shadow-xl p-6 flex flex-col h-full overflow-y-auto">
25
- <h1 class="text-2xl font-bold text-indigo-600 mb-6 flex items-center">
26
- <svg class="w-8 h-8 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>
27
- 口算大师
28
- </h1>
 
 
 
 
 
29
 
30
- <div class="space-y-6 flex-1">
31
 
32
  <!-- Operations -->
33
- <div>
34
- <label class="block text-sm font-medium text-gray-700 mb-2">运算类型</label>
 
 
 
35
  <div class="grid grid-cols-2 gap-3">
36
- <label class="flex items-center space-x-2 border p-2 rounded-lg cursor-pointer hover:bg-indigo-50 transition">
37
- <input type="checkbox" class="op-check form-checkbox text-indigo-600 rounded" value="+" checked>
38
- <span class="text-lg font-bold text-gray-700">+ 加法</span>
39
  </label>
40
- <label class="flex items-center space-x-2 border p-2 rounded-lg cursor-pointer hover:bg-indigo-50 transition">
41
- <input type="checkbox" class="op-check form-checkbox text-indigo-600 rounded" value="-" checked>
42
- <span class="text-lg font-bold text-gray-700">- 减法</span>
43
  </label>
44
- <label class="flex items-center space-x-2 border p-2 rounded-lg cursor-pointer hover:bg-indigo-50 transition">
45
- <input type="checkbox" class="op-check form-checkbox text-indigo-600 rounded" value="×">
46
- <span class="text-lg font-bold text-gray-700">× 乘法</span>
47
  </label>
48
- <label class="flex items-center space-x-2 border p-2 rounded-lg cursor-pointer hover:bg-indigo-50 transition">
49
- <input type="checkbox" class="op-check form-checkbox text-indigo-600 rounded" value="÷">
50
- <span class="text-lg font-bold text-gray-700">÷ 除法</span>
51
  </label>
52
  </div>
53
  </div>
54
 
55
- <!-- Range -->
56
- <div>
57
- <label class="block text-sm font-medium text-gray-700 mb-2">数字范围</label>
58
- <div class="flex items-center space-x-2">
59
- <input type="number" id="minVal" value="1" class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border" placeholder="Min">
60
- <span class="text-gray-400">至</span>
61
- <input type="number" id="maxVal" value="20" class="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 p-2 border" placeholder="Max">
 
 
62
  </div>
63
- </div>
64
 
65
- <!-- Count -->
66
- <div>
67
- <label class="block text-sm font-medium text-gray-700 mb-2">题目数量: <span id="countDisplay" class="text-indigo-600 font-bold">50</span></label>
68
- <input type="range" id="countRange" min="10" max="100" value="50" step="10" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600">
 
 
 
69
  </div>
70
 
71
- <!-- Actions -->
72
- <div class="pt-4 space-y-3">
73
- <button onclick="generatePreview()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-4 rounded-xl shadow-lg transform transition hover:-translate-y-0.5 active:translate-y-0 flex justify-center items-center">
74
- <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
75
- 生成预览
76
- </button>
77
- <button onclick="downloadPDF()" class="w-full bg-white border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50 font-bold py-3 px-4 rounded-xl shadow-sm transform transition hover:-translate-y-0.5 active:translate-y-0 flex justify-center items-center">
78
- <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
79
- 下载 PDF
80
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  </div>
 
82
  </div>
83
 
84
- <div class="mt-6 text-xs text-gray-400 text-center">
85
- Built with Flask & Tailwind for Hugging Face
 
 
 
 
 
 
 
 
86
  </div>
87
  </div>
88
 
89
  <!-- Main: Preview -->
90
- <div class="flex-1 bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col">
91
  <div class="bg-gray-50 px-6 py-4 border-b flex justify-between items-center">
92
- <h2 class="font-bold text-gray-700">试卷预览 (A4 模拟)</h2>
93
- <div class="text-sm text-gray-500">
94
- * 实际 PDF 排版可能略有不同
 
 
 
 
 
95
  </div>
96
  </div>
97
- <div class="flex-1 overflow-y-auto p-8 paper-bg relative">
98
- <div id="previewArea" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 text-lg font-mono text-gray-800">
99
  <!-- Problems go here -->
100
- <div class="text-center text-gray-400 col-span-full py-20">
101
- 点击左侧“生成预览”查看题目
 
102
  </div>
103
  </div>
104
  </div>
@@ -107,22 +160,45 @@
107
  </div>
108
 
109
  <script>
110
- // Update Range Display
111
  const countRange = document.getElementById('countRange');
112
  const countDisplay = document.getElementById('countDisplay');
113
  countRange.addEventListener('input', (e) => countDisplay.innerText = e.target.value);
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  function getSettings() {
116
  const ops = Array.from(document.querySelectorAll('.op-check:checked')).map(cb => cb.value);
117
  const min = parseInt(document.getElementById('minVal').value) || 1;
118
  const max = parseInt(document.getElementById('maxVal').value) || 20;
119
  const count = parseInt(countRange.value) || 50;
 
 
 
120
 
121
  if (ops.length === 0) {
122
  alert('请至少选择一种运算类型!');
123
  return null;
124
  }
125
- return { ops, min, max, count };
126
  }
127
 
128
  async function generatePreview() {
@@ -130,7 +206,17 @@
130
  if (!settings) return;
131
 
132
  const previewArea = document.getElementById('previewArea');
133
- previewArea.innerHTML = '<div class="col-span-full text-center py-20"><div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div></div>';
 
 
 
 
 
 
 
 
 
 
134
 
135
  try {
136
  const res = await axios.post('/api/preview', settings);
@@ -139,11 +225,11 @@
139
  previewArea.innerHTML = '';
140
  problems.forEach((p, index) => {
141
  const el = document.createElement('div');
142
- el.className = 'border-b border-gray-200 py-2 px-4 flex justify-between items-center bg-white/50 rounded hover:bg-white transition';
 
143
  el.innerHTML = `
144
- <span class="font-bold text-gray-700">${index + 1}.</span>
145
- <span class="text-xl tracking-wider">${p.question}</span>
146
- <span class="text-transparent border-b border-gray-400 w-12 text-center group-hover:text-gray-300 select-none">?</span>
147
  `;
148
  previewArea.appendChild(el);
149
  });
@@ -157,8 +243,12 @@
157
  const settings = getSettings();
158
  if (!settings) return;
159
 
 
 
 
 
 
160
  try {
161
- // Use fetch to get blob
162
  const response = await fetch('/api/download', {
163
  method: 'POST',
164
  headers: { 'Content-Type': 'application/json' },
@@ -178,6 +268,9 @@
178
  window.URL.revokeObjectURL(url);
179
  } catch (err) {
180
  alert('下载失败: ' + err.message);
 
 
 
181
  }
182
  }
183
 
 
14
  background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
15
  background-size: 20px 20px;
16
  }
17
+ /* Custom scrollbar */
18
+ ::-webkit-scrollbar { width: 6px; }
19
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
20
  </style>
21
  </head>
22
+ <body class="bg-gray-100 min-h-screen text-gray-800 font-sans">
23
 
24
+ <div class="container mx-auto px-4 py-6 flex flex-col md:flex-row gap-6 h-screen overflow-hidden">
25
 
26
  <!-- Sidebar: Settings -->
27
+ <div class="w-full md:w-1/3 lg:w-1/4 bg-white rounded-2xl shadow-xl flex flex-col h-full border border-indigo-50">
28
+ <div class="p-6 pb-2">
29
+ <h1 class="text-2xl font-bold text-indigo-600 flex items-center">
30
+ <span class="bg-indigo-100 p-2 rounded-lg mr-3">
31
+ <svg class="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>
32
+ </span>
33
+ 口算大师
34
+ </h1>
35
+ <p class="text-xs text-gray-400 mt-1 ml-1">专业的小学数学出题神器</p>
36
+ </div>
37
 
38
+ <div class="flex-1 overflow-y-auto px-6 py-2 space-y-6">
39
 
40
  <!-- Operations -->
41
+ <div class="bg-gray-50 p-4 rounded-xl border border-gray-100">
42
+ <label class="block text-sm font-bold text-gray-700 mb-3 flex items-center">
43
+ <svg class="w-4 h-4 mr-1 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path></svg>
44
+ 运算类型
45
+ </label>
46
  <div class="grid grid-cols-2 gap-3">
47
+ <label class="flex items-center space-x-2 bg-white border p-2 rounded-lg cursor-pointer hover:border-indigo-300 transition shadow-sm">
48
+ <input type="checkbox" class="op-check form-checkbox text-indigo-600 rounded w-5 h-5" value="+" checked>
49
+ <span class="font-medium text-gray-700">+ 加法</span>
50
  </label>
51
+ <label class="flex items-center space-x-2 bg-white border p-2 rounded-lg cursor-pointer hover:border-indigo-300 transition shadow-sm">
52
+ <input type="checkbox" class="op-check form-checkbox text-indigo-600 rounded w-5 h-5" value="-" checked>
53
+ <span class="font-medium text-gray-700">- 减法</span>
54
  </label>
55
+ <label class="flex items-center space-x-2 bg-white border p-2 rounded-lg cursor-pointer hover:border-indigo-300 transition shadow-sm">
56
+ <input type="checkbox" class="op-check form-checkbox text-indigo-600 rounded w-5 h-5" value="×">
57
+ <span class="font-medium text-gray-700">× 乘法</span>
58
  </label>
59
+ <label class="flex items-center space-x-2 bg-white border p-2 rounded-lg cursor-pointer hover:border-indigo-300 transition shadow-sm">
60
+ <input type="checkbox" class="op-check form-checkbox text-indigo-600 rounded w-5 h-5" value="÷">
61
+ <span class="font-medium text-gray-700">÷ 除法</span>
62
  </label>
63
  </div>
64
  </div>
65
 
66
+ <!-- Range & Count -->
67
+ <div class="space-y-4">
68
+ <div>
69
+ <label class="block text-sm font-bold text-gray-700 mb-2">数字范围 (1-100)</label>
70
+ <div class="flex items-center space-x-2">
71
+ <input type="number" id="minVal" value="1" class="w-full bg-gray-50 border-gray-200 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 p-2.5 border text-center font-mono" placeholder="Min">
72
+ <span class="text-gray-400 font-bold">-</span>
73
+ <input type="number" id="maxVal" value="20" class="w-full bg-gray-50 border-gray-200 rounded-lg focus:ring-indigo-500 focus:border-indigo-500 p-2.5 border text-center font-mono" placeholder="Max">
74
+ </div>
75
  </div>
 
76
 
77
+ <div>
78
+ <div class="flex justify-between items-center mb-2">
79
+ <label class="text-sm font-bold text-gray-700">题目数量</label>
80
+ <span id="countDisplay" class="bg-indigo-100 text-indigo-700 px-2 py-0.5 rounded text-sm font-bold">50</span>
81
+ </div>
82
+ <input type="range" id="countRange" min="10" max="100" value="50" step="10" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-indigo-600">
83
+ </div>
84
  </div>
85
 
86
+ <!-- Advanced Options -->
87
+ <div class="bg-indigo-50/50 p-4 rounded-xl border border-indigo-100 space-y-3">
88
+ <h3 class="text-xs font-bold text-indigo-400 uppercase tracking-wider">高级选项</h3>
89
+
90
+ <label class="flex items-center space-x-3 cursor-pointer group">
91
+ <div class="relative flex items-center">
92
+ <input type="checkbox" id="enableBlank" class="peer h-5 w-5 cursor-pointer appearance-none rounded-md border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 focus:outline-none transition-all">
93
+ <svg class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white opacity-0 peer-checked:opacity-100" viewBox="0 0 14 14" fill="none"><path d="M3 8L6 11L11 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
94
+ </div>
95
+ <span class="text-sm font-medium text-gray-700 group-hover:text-indigo-700 transition">填空题模式 (如: 3 + ? = 5)</span>
96
+ </label>
97
+
98
+ <label class="flex items-center space-x-3 cursor-pointer group">
99
+ <div class="relative flex items-center">
100
+ <input type="checkbox" id="enableAnswers" class="peer h-5 w-5 cursor-pointer appearance-none rounded-md border border-gray-300 bg-white checked:border-indigo-600 checked:bg-indigo-600 focus:outline-none transition-all">
101
+ <svg class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white opacity-0 peer-checked:opacity-100" viewBox="0 0 14 14" fill="none"><path d="M3 8L6 11L11 3.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
102
+ </div>
103
+ <span class="text-sm font-medium text-gray-700 group-hover:text-indigo-700 transition">附带答案页 (PDF)</span>
104
+ </label>
105
+
106
+ <div class="flex items-center justify-between text-sm">
107
+ <span class="font-medium text-gray-700">PDF 列数:</span>
108
+ <div class="flex bg-white rounded-lg p-1 border shadow-sm">
109
+ <label class="px-3 py-1 rounded cursor-pointer transition" id="col3_label">
110
+ <input type="radio" name="cols" value="3" class="hidden" onchange="updateColUI(this)">
111
+ 3列
112
+ </label>
113
+ <label class="px-3 py-1 rounded bg-indigo-100 text-indigo-700 font-bold cursor-pointer transition" id="col4_label">
114
+ <input type="radio" name="cols" value="4" class="hidden" onchange="updateColUI(this)" checked>
115
+ 4列
116
+ </label>
117
+ </div>
118
+ </div>
119
  </div>
120
+
121
  </div>
122
 
123
+ <!-- Actions -->
124
+ <div class="p-6 pt-2 space-y-3 bg-white border-t border-gray-100">
125
+ <button onclick="generatePreview()" class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3.5 px-4 rounded-xl shadow-lg shadow-indigo-200 transform transition hover:-translate-y-0.5 active:translate-y-0 flex justify-center items-center">
126
+ <svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
127
+ 生成题目预览
128
+ </button>
129
+ <button onclick="downloadPDF()" class="w-full bg-white border-2 border-indigo-600 text-indigo-600 hover:bg-indigo-50 font-bold py-3.5 px-4 rounded-xl shadow-sm transform transition hover:-translate-y-0.5 active:translate-y-0 flex justify-center items-center group">
130
+ <svg class="w-5 h-5 mr-2 group-hover:animate-bounce" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
131
+ 下载 PDF 试卷
132
+ </button>
133
  </div>
134
  </div>
135
 
136
  <!-- Main: Preview -->
137
+ <div class="flex-1 bg-white rounded-2xl shadow-xl overflow-hidden flex flex-col border border-gray-100">
138
  <div class="bg-gray-50 px-6 py-4 border-b flex justify-between items-center">
139
+ <div class="flex items-center space-x-2">
140
+ <div class="h-3 w-3 rounded-full bg-red-400"></div>
141
+ <div class="h-3 w-3 rounded-full bg-yellow-400"></div>
142
+ <div class="h-3 w-3 rounded-full bg-green-400"></div>
143
+ </div>
144
+ <h2 class="font-bold text-gray-700">A4 试卷预览</h2>
145
+ <div class="text-xs text-gray-400">
146
+ 仅供预览,请以 PDF 为准
147
  </div>
148
  </div>
149
+ <div class="flex-1 overflow-y-auto p-8 paper-bg relative" id="paperContainer">
150
+ <div id="previewArea" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-y-8 gap-x-4 text-xl font-mono text-gray-800">
151
  <!-- Problems go here -->
152
+ <div class="text-center text-gray-400 col-span-full py-32 flex flex-col items-center justify-center">
153
+ <svg class="w-16 h-16 text-gray-200 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>
154
+ <p>请点击左侧“生成题目预览”</p>
155
  </div>
156
  </div>
157
  </div>
 
160
  </div>
161
 
162
  <script>
163
+ // UI Helpers
164
  const countRange = document.getElementById('countRange');
165
  const countDisplay = document.getElementById('countDisplay');
166
  countRange.addEventListener('input', (e) => countDisplay.innerText = e.target.value);
167
 
168
+ function updateColUI(radio) {
169
+ document.querySelectorAll('input[name="cols"]').forEach(r => {
170
+ const label = r.parentElement;
171
+ if (r.checked) {
172
+ label.classList.add('bg-indigo-100', 'text-indigo-700', 'font-bold');
173
+ } else {
174
+ label.classList.remove('bg-indigo-100', 'text-indigo-700', 'font-bold');
175
+ }
176
+ });
177
+ // Update preview grid if generated
178
+ const grid = document.getElementById('previewArea');
179
+ if (radio.value == '3') {
180
+ grid.classList.remove('lg:grid-cols-4');
181
+ grid.classList.add('lg:grid-cols-3');
182
+ } else {
183
+ grid.classList.remove('lg:grid-cols-3');
184
+ grid.classList.add('lg:grid-cols-4');
185
+ }
186
+ }
187
+
188
  function getSettings() {
189
  const ops = Array.from(document.querySelectorAll('.op-check:checked')).map(cb => cb.value);
190
  const min = parseInt(document.getElementById('minVal').value) || 1;
191
  const max = parseInt(document.getElementById('maxVal').value) || 20;
192
  const count = parseInt(countRange.value) || 50;
193
+ const blank_prob = document.getElementById('enableBlank').checked ? 0.5 : 0;
194
+ const with_answers = document.getElementById('enableAnswers').checked;
195
+ const cols = document.querySelector('input[name="cols"]:checked').value;
196
 
197
  if (ops.length === 0) {
198
  alert('请至少选择一种运算类型!');
199
  return null;
200
  }
201
+ return { ops, min, max, count, blank_prob, with_answers, cols };
202
  }
203
 
204
  async function generatePreview() {
 
206
  if (!settings) return;
207
 
208
  const previewArea = document.getElementById('previewArea');
209
+
210
+ // Apply grid cols immediately for visual feedback
211
+ if (settings.cols == '3') {
212
+ previewArea.classList.remove('lg:grid-cols-4');
213
+ previewArea.classList.add('lg:grid-cols-3');
214
+ } else {
215
+ previewArea.classList.remove('lg:grid-cols-3');
216
+ previewArea.classList.add('lg:grid-cols-4');
217
+ }
218
+
219
+ previewArea.innerHTML = '<div class="col-span-full text-center py-32"><div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div></div>';
220
 
221
  try {
222
  const res = await axios.post('/api/preview', settings);
 
225
  previewArea.innerHTML = '';
226
  problems.forEach((p, index) => {
227
  const el = document.createElement('div');
228
+ // Simulating paper look
229
+ el.className = 'py-2 px-2 flex items-center hover:bg-indigo-50/50 rounded transition select-text';
230
  el.innerHTML = `
231
+ <span class="font-bold text-gray-400 mr-3 text-sm w-6 text-right">${index + 1}.</span>
232
+ <span class="text-2xl font-mono text-gray-800 tracking-wide">${p.question.replace(/\s/g, '&nbsp;')}</span>
 
233
  `;
234
  previewArea.appendChild(el);
235
  });
 
243
  const settings = getSettings();
244
  if (!settings) return;
245
 
246
+ const btn = event.currentTarget;
247
+ const originalText = btn.innerHTML;
248
+ btn.innerHTML = '<svg class="animate-spin h-5 w-5 mr-2 text-indigo-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>生成中...';
249
+ btn.disabled = true;
250
+
251
  try {
 
252
  const response = await fetch('/api/download', {
253
  method: 'POST',
254
  headers: { 'Content-Type': 'application/json' },
 
268
  window.URL.revokeObjectURL(url);
269
  } catch (err) {
270
  alert('下载失败: ' + err.message);
271
+ } finally {
272
+ btn.innerHTML = originalText;
273
+ btn.disabled = false;
274
  }
275
  }
276