kumiki 0.1.1

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 (62) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +256 -0
  4. data/lib/kumiki/animation/animated_state.rb +83 -0
  5. data/lib/kumiki/animation/easing.rb +62 -0
  6. data/lib/kumiki/animation/value_tween.rb +69 -0
  7. data/lib/kumiki/app.rb +381 -0
  8. data/lib/kumiki/box.rb +40 -0
  9. data/lib/kumiki/chart/area_chart.rb +308 -0
  10. data/lib/kumiki/chart/bar_chart.rb +291 -0
  11. data/lib/kumiki/chart/base_chart.rb +213 -0
  12. data/lib/kumiki/chart/chart_helpers.rb +74 -0
  13. data/lib/kumiki/chart/gauge_chart.rb +174 -0
  14. data/lib/kumiki/chart/heatmap_chart.rb +223 -0
  15. data/lib/kumiki/chart/line_chart.rb +292 -0
  16. data/lib/kumiki/chart/pie_chart.rb +222 -0
  17. data/lib/kumiki/chart/scales.rb +79 -0
  18. data/lib/kumiki/chart/scatter_chart.rb +306 -0
  19. data/lib/kumiki/chart/stacked_bar_chart.rb +279 -0
  20. data/lib/kumiki/column.rb +351 -0
  21. data/lib/kumiki/core.rb +2511 -0
  22. data/lib/kumiki/dsl.rb +408 -0
  23. data/lib/kumiki/frame_ranma.rb +570 -0
  24. data/lib/kumiki/markdown/ast.rb +127 -0
  25. data/lib/kumiki/markdown/mermaid/layout.rb +389 -0
  26. data/lib/kumiki/markdown/mermaid/models.rb +235 -0
  27. data/lib/kumiki/markdown/mermaid/parser.rb +522 -0
  28. data/lib/kumiki/markdown/mermaid/renderer.rb +339 -0
  29. data/lib/kumiki/markdown/parser.rb +808 -0
  30. data/lib/kumiki/markdown/renderer.rb +642 -0
  31. data/lib/kumiki/markdown/theme.rb +168 -0
  32. data/lib/kumiki/render_node.rb +262 -0
  33. data/lib/kumiki/row.rb +288 -0
  34. data/lib/kumiki/spacer.rb +20 -0
  35. data/lib/kumiki/style.rb +799 -0
  36. data/lib/kumiki/theme.rb +567 -0
  37. data/lib/kumiki/themes/material.rb +40 -0
  38. data/lib/kumiki/themes/tokyo_night.rb +11 -0
  39. data/lib/kumiki/version.rb +5 -0
  40. data/lib/kumiki/widgets/button.rb +105 -0
  41. data/lib/kumiki/widgets/calendar.rb +1028 -0
  42. data/lib/kumiki/widgets/checkbox.rb +119 -0
  43. data/lib/kumiki/widgets/container.rb +111 -0
  44. data/lib/kumiki/widgets/data_table.rb +670 -0
  45. data/lib/kumiki/widgets/divider.rb +31 -0
  46. data/lib/kumiki/widgets/image.rb +105 -0
  47. data/lib/kumiki/widgets/input.rb +485 -0
  48. data/lib/kumiki/widgets/markdown.rb +58 -0
  49. data/lib/kumiki/widgets/modal.rb +165 -0
  50. data/lib/kumiki/widgets/multiline_input.rb +970 -0
  51. data/lib/kumiki/widgets/multiline_text.rb +180 -0
  52. data/lib/kumiki/widgets/net_image.rb +100 -0
  53. data/lib/kumiki/widgets/progress_bar.rb +72 -0
  54. data/lib/kumiki/widgets/radio_buttons.rb +93 -0
  55. data/lib/kumiki/widgets/slider.rb +135 -0
  56. data/lib/kumiki/widgets/switch.rb +84 -0
  57. data/lib/kumiki/widgets/tabs.rb +175 -0
  58. data/lib/kumiki/widgets/text.rb +120 -0
  59. data/lib/kumiki/widgets/tree.rb +434 -0
  60. data/lib/kumiki/widgets/webview.rb +87 -0
  61. data/lib/kumiki.rb +130 -0
  62. metadata +113 -0
@@ -0,0 +1,642 @@
1
+ module Kumiki
2
+ # Markdown renderer - cursor-based rendering using kumiki's painter API
3
+ # Walks the MdNode AST and emits drawing commands
4
+
5
+ class MarkdownRenderer
6
+ def initialize(theme)
7
+ @theme = theme
8
+ @cursor_x = 0.0
9
+ @cursor_y = 0.0
10
+ @list_depth = 0
11
+ @list_counters = []
12
+ @font_family = Kumiki.theme.font_family
13
+ end
14
+
15
+ def render(painter, ast, width, padding)
16
+ @cursor_x = padding
17
+ @cursor_y = padding
18
+ @list_depth = 0
19
+ @list_counters = []
20
+ content_width = width - padding * 2.0
21
+ if content_width < 1.0
22
+ content_width = 1.0
23
+ end
24
+ i = 0
25
+ while i < ast.children.length
26
+ render_node(painter, ast.children[i], content_width)
27
+ i = i + 1
28
+ end
29
+ @cursor_y + padding
30
+ end
31
+
32
+ def measure_height(painter, ast, width, padding)
33
+ total = padding * 2.0
34
+ content_width = width - padding * 2.0
35
+ if content_width < 1.0
36
+ content_width = 1.0
37
+ end
38
+ i = 0
39
+ while i < ast.children.length
40
+ total = total + estimate_node_height(painter, ast.children[i], content_width)
41
+ i = i + 1
42
+ end
43
+ total
44
+ end
45
+
46
+ def render_node(painter, node, width)
47
+ t = node.type
48
+ if t == MD_HEADING
49
+ render_heading(painter, node, width)
50
+ elsif t == MD_PARAGRAPH
51
+ render_paragraph(painter, node, width)
52
+ elsif t == MD_CODE_BLOCK
53
+ render_code_block(painter, node, width)
54
+ elsif t == MD_BLOCKQUOTE
55
+ render_blockquote(painter, node, width)
56
+ elsif t == MD_LIST
57
+ render_list(painter, node, width)
58
+ elsif t == MD_TABLE
59
+ render_table(painter, node, width)
60
+ elsif t == MD_HORIZONTAL_RULE
61
+ render_hr(painter, width)
62
+ elsif t == MD_IMAGE
63
+ render_image(painter, node, width)
64
+ elsif t == MD_MERMAID
65
+ render_mermaid(painter, node, width)
66
+ end
67
+ end
68
+
69
+ # --- Heading ---
70
+ def render_heading(painter, node, width)
71
+ font_size = @theme.heading_size(node.level)
72
+ @cursor_y = @cursor_y + @theme.block_spacing
73
+
74
+ text = extract_text(node.children)
75
+ ascent = painter.get_text_ascent(@font_family, font_size)
76
+ color = @theme.heading_color
77
+
78
+ # Faux bold: draw twice with 0.5px offset
79
+ painter.draw_text(text, @cursor_x, @cursor_y + ascent, @font_family, font_size, color)
80
+ painter.draw_text(text, @cursor_x + 0.5, @cursor_y + ascent, @font_family, font_size, color)
81
+
82
+ @cursor_y = @cursor_y + font_size * 1.5
83
+ end
84
+
85
+ # --- Paragraph ---
86
+ def render_paragraph(painter, node, width)
87
+ segments = collect_segments(node.children, false, false, false, false, "")
88
+ font_size = @theme.base_font_size
89
+ line_height = font_size * 1.5
90
+ ascent = painter.get_text_ascent(@font_family, font_size)
91
+
92
+ indent = @list_depth * @theme.list_indent
93
+ x = @cursor_x + indent
94
+ available_width = width - indent
95
+ start_x = x
96
+
97
+ si = 0
98
+ while si < segments.length
99
+ seg = segments[si]
100
+ seg_text = seg[0]
101
+ seg_bold = seg[1]
102
+ seg_italic = seg[2]
103
+ seg_strike = seg[3]
104
+ seg_code = seg[4]
105
+ seg_href = seg[5]
106
+
107
+ # Determine color
108
+ if seg_href.length > 0
109
+ color = @theme.link_color
110
+ elsif seg_code
111
+ color = @theme.code_color
112
+ elsif seg_italic
113
+ color = @theme.emphasis_color
114
+ elsif seg_strike
115
+ color = @theme.strikethrough_color
116
+ else
117
+ color = @theme.text_color
118
+ end
119
+
120
+ # Split segment text by words for wrapping
121
+ words = seg_text.split(" ")
122
+ wi = 0
123
+ while wi < words.length
124
+ word = words[wi]
125
+ if wi > 0
126
+ word = " " + word
127
+ end
128
+ word_w = painter.measure_text_width(word, @font_family, font_size)
129
+
130
+ # Check if word fits on current line
131
+ if x + word_w > @cursor_x + available_width && x > start_x
132
+ # Wrap to next line
133
+ @cursor_y = @cursor_y + line_height
134
+ x = start_x
135
+ # Trim leading space after wrap
136
+ if word.start_with?(" ")
137
+ word = word[1, word.length - 1]
138
+ word_w = painter.measure_text_width(word, @font_family, font_size)
139
+ end
140
+ end
141
+
142
+ # Draw inline code background
143
+ if seg_code
144
+ code_pad = 3.0
145
+ painter.fill_round_rect(x - code_pad, @cursor_y, word_w + code_pad * 2.0, font_size * 1.2, 3.0, @theme.code_inline_bg)
146
+ end
147
+
148
+ # Draw text
149
+ painter.draw_text(word, x, @cursor_y + ascent, @font_family, font_size, color)
150
+
151
+ # Faux bold
152
+ if seg_bold
153
+ painter.draw_text(word, x + 0.5, @cursor_y + ascent, @font_family, font_size, color)
154
+ end
155
+
156
+ # Strikethrough line
157
+ if seg_strike
158
+ strike_y = @cursor_y + font_size * 0.55
159
+ painter.draw_line(x, strike_y, x + word_w, strike_y, color, 1.0)
160
+ end
161
+
162
+ # Link underline
163
+ if seg_href.length > 0
164
+ underline_y = @cursor_y + ascent + 2.0
165
+ painter.fill_rect(x, underline_y, word_w, 1.0, color)
166
+ end
167
+
168
+ x = x + word_w
169
+ wi = wi + 1
170
+ end
171
+
172
+ si = si + 1
173
+ end
174
+
175
+ @cursor_y = @cursor_y + line_height + @theme.paragraph_spacing
176
+ end
177
+
178
+ # --- Code block ---
179
+ def render_code_block(painter, node, width)
180
+ font_size = @theme.base_font_size
181
+ line_height = font_size + 4.0
182
+ code_padding = 8.0
183
+
184
+ lines = node.content.split("\n")
185
+ block_height = lines.length * line_height + code_padding * 2.0
186
+
187
+ # Background
188
+ painter.fill_round_rect(@cursor_x, @cursor_y, width, block_height, 4.0, @theme.code_bg_color)
189
+
190
+ # Lines
191
+ ascent = painter.get_text_ascent(@font_family, font_size)
192
+ y = @cursor_y + code_padding + ascent
193
+ li = 0
194
+ while li < lines.length
195
+ painter.draw_text(lines[li], @cursor_x + code_padding, y, @font_family, font_size, @theme.code_color)
196
+ y = y + line_height
197
+ li = li + 1
198
+ end
199
+
200
+ @cursor_y = @cursor_y + block_height + @theme.block_spacing
201
+ end
202
+
203
+ # --- Blockquote ---
204
+ def render_blockquote(painter, node, width)
205
+ indent = @theme.blockquote_indent
206
+ bar_width = 4.0
207
+ start_y = @cursor_y
208
+
209
+ @cursor_x = @cursor_x + indent
210
+ ci = 0
211
+ while ci < node.children.length
212
+ render_node(painter, node.children[ci], width - indent)
213
+ ci = ci + 1
214
+ end
215
+ @cursor_x = @cursor_x - indent
216
+
217
+ # Draw left bar
218
+ bar_height = @cursor_y - start_y
219
+ if bar_height > 0.0
220
+ painter.fill_rect(@cursor_x, start_y, bar_width, bar_height, @theme.blockquote_bg)
221
+ end
222
+ end
223
+
224
+ # --- List ---
225
+ def render_list(painter, node, width)
226
+ @list_depth = @list_depth + 1
227
+ counter = node.start_num
228
+ if node.ordered
229
+ @list_counters.push(counter)
230
+ end
231
+
232
+ ci = 0
233
+ while ci < node.children.length
234
+ child = node.children[ci]
235
+ if child.type == MD_LIST_ITEM
236
+ render_list_item(painter, child, width, node.ordered)
237
+ if node.ordered
238
+ counter = counter + 1
239
+ if @list_counters.length > 0
240
+ @list_counters[@list_counters.length - 1] = counter
241
+ end
242
+ end
243
+ end
244
+ ci = ci + 1
245
+ end
246
+
247
+ if node.ordered && @list_counters.length > 0
248
+ @list_counters.pop
249
+ end
250
+ @list_depth = @list_depth - 1
251
+ end
252
+
253
+ def render_list_item(painter, node, width, ordered)
254
+ indent = @list_depth * @theme.list_indent
255
+ font_size = @theme.base_font_size
256
+ ascent = painter.get_text_ascent(@font_family, font_size)
257
+ color = @theme.text_color
258
+
259
+ marker_x = @cursor_x + indent - 16.0
260
+ marker_y = @cursor_y + ascent
261
+
262
+ if node.checked >= 0
263
+ # Task list item: draw checkbox
264
+ box_size = font_size * 0.75
265
+ box_x = marker_x
266
+ box_y = @cursor_y + (font_size * 1.5 - box_size) / 2.0
267
+ if node.checked == 1
268
+ # Filled checkbox
269
+ painter.fill_rect(box_x, box_y, box_size, box_size, @theme.checkbox_checked_color)
270
+ # Draw checkmark as text
271
+ painter.draw_text("v", box_x + 1.0, box_y + box_size - 2.0, @font_family, font_size * 0.65, 0xFFFFFFFF)
272
+ else
273
+ # Empty checkbox outline (draw 4 sides)
274
+ painter.fill_rect(box_x, box_y, box_size, 1.0, @theme.checkbox_unchecked_color)
275
+ painter.fill_rect(box_x, box_y + box_size - 1.0, box_size, 1.0, @theme.checkbox_unchecked_color)
276
+ painter.fill_rect(box_x, box_y, 1.0, box_size, @theme.checkbox_unchecked_color)
277
+ painter.fill_rect(box_x + box_size - 1.0, box_y, 1.0, box_size, @theme.checkbox_unchecked_color)
278
+ end
279
+ elsif ordered
280
+ counter = 1
281
+ if @list_counters.length > 0
282
+ counter = @list_counters[@list_counters.length - 1]
283
+ end
284
+ marker = counter.to_s + "."
285
+ painter.draw_text(marker, marker_x, marker_y, @font_family, font_size, color)
286
+ else
287
+ painter.draw_text("*", marker_x + 4.0, marker_y, @font_family, font_size, color)
288
+ end
289
+
290
+ # Render item children
291
+ ci = 0
292
+ while ci < node.children.length
293
+ render_node(painter, node.children[ci], width)
294
+ ci = ci + 1
295
+ end
296
+ end
297
+
298
+ # --- Horizontal rule ---
299
+ def render_hr(painter, width)
300
+ @cursor_y = @cursor_y + @theme.block_spacing / 2.0
301
+ painter.fill_rect(@cursor_x, @cursor_y, width, 1.0, @theme.text_color)
302
+ @cursor_y = @cursor_y + @theme.block_spacing / 2.0 + 1.0
303
+ end
304
+
305
+ # --- Table ---
306
+ def render_table(painter, node, width)
307
+ font_size = @theme.base_font_size
308
+ ascent = painter.get_text_ascent(@font_family, font_size)
309
+ cell_pad = 8.0
310
+ row_height = font_size * 1.5 + cell_pad * 2.0
311
+
312
+ # Count columns from header row
313
+ num_cols = 0
314
+ if node.children.length > 0
315
+ num_cols = node.children[0].children.length
316
+ end
317
+ if num_cols == 0
318
+ return
319
+ end
320
+
321
+ # Measure phase: compute max cell text width per column
322
+ col_widths = []
323
+ ci = 0
324
+ while ci < num_cols
325
+ col_widths.push(0.0)
326
+ ci = ci + 1
327
+ end
328
+
329
+ ri = 0
330
+ while ri < node.children.length
331
+ row = node.children[ri]
332
+ ci = 0
333
+ while ci < row.children.length && ci < num_cols
334
+ cell = row.children[ci]
335
+ text = extract_text(cell.children)
336
+ text_w = painter.measure_text_width(text, @font_family, font_size)
337
+ cell_w = text_w + cell_pad * 2.0
338
+ if cell_w > col_widths[ci]
339
+ col_widths[ci] = cell_w
340
+ end
341
+ ci = ci + 1
342
+ end
343
+ ri = ri + 1
344
+ end
345
+
346
+ # Distribute widths: scale to fit available width
347
+ total_w = 0.0
348
+ ci = 0
349
+ while ci < num_cols
350
+ total_w = total_w + col_widths[ci]
351
+ ci = ci + 1
352
+ end
353
+
354
+ if total_w < width && total_w > 0.0
355
+ # Scale up proportionally
356
+ scale = width / total_w
357
+ ci = 0
358
+ while ci < num_cols
359
+ col_widths[ci] = col_widths[ci] * scale
360
+ ci = ci + 1
361
+ end
362
+ elsif total_w > width && total_w > 0.0
363
+ # Scale down proportionally
364
+ scale = width / total_w
365
+ ci = 0
366
+ while ci < num_cols
367
+ col_widths[ci] = col_widths[ci] * scale
368
+ ci = ci + 1
369
+ end
370
+ end
371
+
372
+ table_x = @cursor_x
373
+ table_width = 0.0
374
+ ci = 0
375
+ while ci < num_cols
376
+ table_width = table_width + col_widths[ci]
377
+ ci = ci + 1
378
+ end
379
+
380
+ @cursor_y = @cursor_y + @theme.block_spacing / 2.0
381
+
382
+ # Draw rows
383
+ ri = 0
384
+ while ri < node.children.length
385
+ row = node.children[ri]
386
+ row_y = @cursor_y
387
+
388
+ # Header row background
389
+ if row.is_header
390
+ painter.fill_rect(table_x, row_y, table_width, row_height, @theme.table_header_bg)
391
+ end
392
+
393
+ # Draw cells
394
+ cell_x = table_x
395
+ ci = 0
396
+ while ci < row.children.length && ci < num_cols
397
+ cell = row.children[ci]
398
+ text = extract_text(cell.children)
399
+ text_w = painter.measure_text_width(text, @font_family, font_size)
400
+ col_w = col_widths[ci]
401
+
402
+ # Compute text x position based on alignment
403
+ align = cell.align
404
+ if align == 1
405
+ # Center
406
+ tx = cell_x + (col_w - text_w) / 2.0
407
+ elsif align == 2
408
+ # Right
409
+ tx = cell_x + col_w - text_w - cell_pad
410
+ else
411
+ # Left
412
+ tx = cell_x + cell_pad
413
+ end
414
+
415
+ text_y = row_y + cell_pad + ascent
416
+ color = @theme.text_color
417
+
418
+ painter.draw_text(text, tx, text_y, @font_family, font_size, color)
419
+ # Faux bold for header
420
+ if row.is_header
421
+ painter.draw_text(text, tx + 0.5, text_y, @font_family, font_size, color)
422
+ end
423
+
424
+ cell_x = cell_x + col_w
425
+ ci = ci + 1
426
+ end
427
+
428
+ # Draw horizontal line below row
429
+ painter.fill_rect(table_x, row_y + row_height, table_width, 1.0, @theme.table_border_color)
430
+
431
+ @cursor_y = @cursor_y + row_height + 1.0
432
+ ri = ri + 1
433
+ end
434
+
435
+ # Draw vertical column separator lines
436
+ line_x = table_x
437
+ table_top = @cursor_y - (node.children.length * (row_height + 1.0))
438
+ table_bottom = @cursor_y
439
+ ci = 0
440
+ while ci < num_cols + 1
441
+ painter.fill_rect(line_x, table_top, 1.0, table_bottom - table_top, @theme.table_border_color)
442
+ if ci < num_cols
443
+ line_x = line_x + col_widths[ci]
444
+ end
445
+ ci = ci + 1
446
+ end
447
+
448
+ @cursor_y = @cursor_y + @theme.block_spacing / 2.0
449
+ end
450
+
451
+ # --- Image ---
452
+ def render_image(painter, node, width)
453
+ # Load image (Java side caches by path hash)
454
+ img_id = painter.load_image(node.href)
455
+ if img_id == 0
456
+ render_image_placeholder(painter, node, width)
457
+ return
458
+ end
459
+ # Get natural dimensions
460
+ img_w = painter.get_image_width(img_id) * 1.0
461
+ img_h = painter.get_image_height(img_id) * 1.0
462
+ if img_w < 1.0 || img_h < 1.0
463
+ render_image_placeholder(painter, node, width)
464
+ return
465
+ end
466
+ # Scale to fit width (maintain aspect ratio)
467
+ if img_w > width
468
+ scale = width / img_w
469
+ img_w = width
470
+ img_h = img_h * scale
471
+ end
472
+ painter.draw_image(img_id, @cursor_x, @cursor_y, img_w, img_h)
473
+ @cursor_y = @cursor_y + img_h + @theme.block_spacing
474
+ end
475
+
476
+ def render_image_placeholder(painter, node, width)
477
+ # Draw a placeholder box with alt text
478
+ box_h = 60.0
479
+ font_size = @theme.base_font_size
480
+ ascent = painter.get_text_ascent(@font_family, font_size)
481
+
482
+ painter.fill_round_rect(@cursor_x, @cursor_y, width, box_h, 4.0, @theme.code_bg_color)
483
+ painter.stroke_round_rect(@cursor_x, @cursor_y, width, box_h, 4.0, @theme.table_border_color, 1.0)
484
+
485
+ # Icon placeholder text
486
+ icon_text = "[Image]"
487
+ if node.content.length > 0
488
+ icon_text = "[" + node.content + "]"
489
+ end
490
+ text_w = painter.measure_text_width(icon_text, @font_family, font_size)
491
+ tx = @cursor_x + (width - text_w) / 2.0
492
+ ty = @cursor_y + (box_h + ascent) / 2.0
493
+ painter.draw_text(icon_text, tx, ty, @font_family, font_size, @theme.strikethrough_color)
494
+
495
+ @cursor_y = @cursor_y + box_h + @theme.block_spacing
496
+ end
497
+
498
+ # --- Mermaid ---
499
+ def render_mermaid(painter, node, width)
500
+ mermaid_parser = MermaidParser.new
501
+ diagram = mermaid_parser.parse(node.content)
502
+ mermaid_renderer = MermaidRenderer.new(@theme)
503
+ height = mermaid_renderer.render(painter, diagram, @cursor_x, @cursor_y, width)
504
+ @cursor_y = @cursor_y + height + @theme.block_spacing
505
+ end
506
+
507
+ # --- Segment collection ---
508
+
509
+ def collect_segments(nodes, bold, italic, strikethrough, code, href)
510
+ segments = []
511
+ i = 0
512
+ while i < nodes.length
513
+ node = nodes[i]
514
+ t = node.type
515
+ if t == MD_TEXT
516
+ segments.push([node.content, bold, italic, strikethrough, code, href])
517
+ elsif t == MD_STRONG
518
+ inner = collect_segments(node.children, true, italic, strikethrough, code, href)
519
+ si = 0
520
+ while si < inner.length
521
+ segments.push(inner[si])
522
+ si = si + 1
523
+ end
524
+ elsif t == MD_EMPHASIS
525
+ inner = collect_segments(node.children, bold, true, strikethrough, code, href)
526
+ si = 0
527
+ while si < inner.length
528
+ segments.push(inner[si])
529
+ si = si + 1
530
+ end
531
+ elsif t == MD_STRIKETHROUGH
532
+ inner = collect_segments(node.children, bold, italic, true, code, href)
533
+ si = 0
534
+ while si < inner.length
535
+ segments.push(inner[si])
536
+ si = si + 1
537
+ end
538
+ elsif t == MD_CODE_INLINE
539
+ segments.push([node.content, bold, italic, strikethrough, true, href])
540
+ elsif t == MD_LINK
541
+ inner = collect_segments(node.children, bold, italic, strikethrough, code, node.href)
542
+ si = 0
543
+ while si < inner.length
544
+ segments.push(inner[si])
545
+ si = si + 1
546
+ end
547
+ elsif t == MD_SOFT_BREAK
548
+ segments.push([" ", bold, italic, strikethrough, code, href])
549
+ end
550
+ i = i + 1
551
+ end
552
+ segments
553
+ end
554
+
555
+ # --- Height estimation ---
556
+
557
+ def estimate_node_height(painter, node, width)
558
+ t = node.type
559
+ if t == MD_HEADING
560
+ font_size = @theme.heading_size(node.level)
561
+ font_size * 1.5 + @theme.block_spacing
562
+ elsif t == MD_PARAGRAPH
563
+ text = extract_text(node.children)
564
+ font_size = @theme.base_font_size
565
+ text_w = painter.measure_text_width(text, @font_family, font_size)
566
+ safe_width = width
567
+ if safe_width < 1.0
568
+ safe_width = 1.0
569
+ end
570
+ lines = (text_w / safe_width).to_i + 1
571
+ if lines < 1
572
+ lines = 1
573
+ end
574
+ lines * font_size * 1.5 + @theme.paragraph_spacing
575
+ elsif t == MD_CODE_BLOCK
576
+ line_count = node.content.split("\n").length
577
+ line_count * (@theme.base_font_size + 4.0) + 16.0 + @theme.block_spacing
578
+ elsif t == MD_BLOCKQUOTE
579
+ h = 0.0
580
+ ci = 0
581
+ while ci < node.children.length
582
+ child_width = width - @theme.blockquote_indent
583
+ if child_width < 1.0
584
+ child_width = 1.0
585
+ end
586
+ h = h + estimate_node_height(painter, node.children[ci], child_width)
587
+ ci = ci + 1
588
+ end
589
+ h
590
+ elsif t == MD_LIST
591
+ h = 0.0
592
+ ci = 0
593
+ while ci < node.children.length
594
+ h = h + estimate_node_height(painter, node.children[ci], width)
595
+ ci = ci + 1
596
+ end
597
+ h
598
+ elsif t == MD_LIST_ITEM
599
+ h = 0.0
600
+ ci = 0
601
+ while ci < node.children.length
602
+ h = h + estimate_node_height(painter, node.children[ci], width)
603
+ ci = ci + 1
604
+ end
605
+ h
606
+ elsif t == MD_TABLE
607
+ cell_pad = 8.0
608
+ row_h = @theme.base_font_size * 1.5 + cell_pad * 2.0 + 1.0
609
+ num_rows = node.children.length
610
+ num_rows * row_h + @theme.block_spacing
611
+ elsif t == MD_HORIZONTAL_RULE
612
+ @theme.block_spacing + 1.0
613
+ elsif t == MD_IMAGE
614
+ 200.0 + @theme.block_spacing
615
+ elsif t == MD_MERMAID
616
+ 300.0 + @theme.block_spacing
617
+ else
618
+ @theme.base_font_size * 1.5
619
+ end
620
+ end
621
+
622
+ # --- Helpers ---
623
+
624
+ def extract_text(nodes)
625
+ parts = []
626
+ i = 0
627
+ while i < nodes.length
628
+ node = nodes[i]
629
+ if node.type == MD_TEXT
630
+ parts.push(node.content)
631
+ elsif node.type == MD_CODE_INLINE
632
+ parts.push(node.content)
633
+ else
634
+ parts.push(extract_text(node.children))
635
+ end
636
+ i = i + 1
637
+ end
638
+ parts.join("")
639
+ end
640
+ end
641
+
642
+ end