Mayo commited on
Commit
33abf07
·
unverified ·
1 Parent(s): 3017cfb

refactor(renderer): bubble-aware latin layout with BubbleIndex lookup

Browse files
koharu-renderer/Cargo.toml CHANGED
@@ -12,7 +12,6 @@ keywords.workspace = true
12
  publish.workspace = true
13
 
14
  [dependencies]
15
- koharu-core = { workspace = true }
16
  anyhow = { workspace = true }
17
  image = { workspace = true }
18
  imageproc = { workspace = true }
 
12
  publish.workspace = true
13
 
14
  [dependencies]
 
15
  anyhow = { workspace = true }
16
  image = { workspace = true }
17
  imageproc = { workspace = true }
koharu-renderer/src/layout.rs CHANGED
@@ -10,7 +10,7 @@ use skrifa::{
10
  use crate::font::{Font, font_key};
11
  use crate::shape::shape_segment_with_fallbacks;
12
  use crate::text::script::shaping_direction_for_text;
13
- use koharu_core::TextAlign;
14
 
15
  pub use crate::segment::{LineBreakOpportunity, LineBreaker, LineSegment};
16
  pub use crate::shape::{PositionedGlyph, ShapedRun, ShapingOptions, TextShaper};
@@ -730,7 +730,7 @@ mod tests {
730
  let layout = TextLayout::new(&font, Some(16.0))
731
  .with_writing_mode(WritingMode::VerticalRl)
732
  .with_max_width(max_width)
733
- .with_alignment(koharu_core::TextAlign::Center)
734
  .run("A")?;
735
 
736
  assert_eq!(layout.width, max_width);
@@ -748,7 +748,7 @@ mod tests {
748
  let layout = TextLayout::new(&font, Some(16.0))
749
  .with_writing_mode(WritingMode::VerticalRl)
750
  .with_max_width(max_width)
751
- .with_alignment(koharu_core::TextAlign::Left)
752
  .run("A")?;
753
 
754
  assert_eq!(layout.width, max_width);
@@ -758,6 +758,32 @@ mod tests {
758
  Ok(())
759
  }
760
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
761
  #[test]
762
  fn fullwidth_punctuation_detection_works() {
763
  assert!(is_fullwidth_punctuation('。'));
 
10
  use crate::font::{Font, font_key};
11
  use crate::shape::shape_segment_with_fallbacks;
12
  use crate::text::script::shaping_direction_for_text;
13
+ use crate::types::TextAlign;
14
 
15
  pub use crate::segment::{LineBreakOpportunity, LineBreaker, LineSegment};
16
  pub use crate::shape::{PositionedGlyph, ShapedRun, ShapingOptions, TextShaper};
 
730
  let layout = TextLayout::new(&font, Some(16.0))
731
  .with_writing_mode(WritingMode::VerticalRl)
732
  .with_max_width(max_width)
733
+ .with_alignment(TextAlign::Center)
734
  .run("A")?;
735
 
736
  assert_eq!(layout.width, max_width);
 
748
  let layout = TextLayout::new(&font, Some(16.0))
749
  .with_writing_mode(WritingMode::VerticalRl)
750
  .with_max_width(max_width)
751
+ .with_alignment(TextAlign::Left)
752
  .run("A")?;
753
 
754
  assert_eq!(layout.width, max_width);
 
758
  Ok(())
759
  }
760
 
761
+ #[test]
762
+ fn horizontal_center_alignment_centres_short_lines() -> anyhow::Result<()> {
763
+ // Two lines of clearly different widths — a wide "HELLOWORLD" and
764
+ // a narrow "HI". In a max_width wider than the long line, the
765
+ // narrow line should be offset so its centre matches the long
766
+ // line's centre (and the sprite centre).
767
+ let font = any_system_font();
768
+ let max_width = 400.0;
769
+ let layout = TextLayout::new(&font, Some(20.0))
770
+ .with_max_width(max_width)
771
+ .with_alignment(TextAlign::Center)
772
+ .run("HELLOWORLD\nHI")?;
773
+
774
+ assert_eq!(layout.lines.len(), 2);
775
+ let w0 = layout.lines[0].advance;
776
+ let w1 = layout.lines[1].advance;
777
+ let c0 = layout.lines[0].baseline.0 + w0 * 0.5;
778
+ let c1 = layout.lines[1].baseline.0 + w1 * 0.5;
779
+ // Line centres must coincide (within rounding / float slack).
780
+ assert!(
781
+ (c0 - c1).abs() < 1.0,
782
+ "expected line centres to match, got c0={c0} c1={c1}",
783
+ );
784
+ Ok(())
785
+ }
786
+
787
  #[test]
788
  fn fullwidth_punctuation_detection_works() {
789
  assert!(is_fullwidth_punctuation('。'));
koharu-renderer/src/lib.rs CHANGED
@@ -4,3 +4,6 @@ pub mod renderer;
4
  pub mod segment;
5
  pub mod shape;
6
  pub mod text;
 
 
 
 
4
  pub mod segment;
5
  pub mod shape;
6
  pub mod text;
7
+ pub mod types;
8
+
9
+ pub use types::{RenderBlock, TextAlign, TextDirection, TextShaderEffect};
koharu-renderer/src/renderer.rs CHANGED
@@ -15,7 +15,7 @@ use tiny_skia::{
15
  use crate::font::{Font, font_key};
16
  use crate::layout::{LayoutRun, PositionedGlyph, WritingMode};
17
 
18
- pub use koharu_core::TextShaderEffect;
19
 
20
  #[derive(Debug, Clone, Copy)]
21
  pub struct RenderStrokeOptions {
 
15
  use crate::font::{Font, font_key};
16
  use crate::layout::{LayoutRun, PositionedGlyph, WritingMode};
17
 
18
+ pub use crate::types::TextShaderEffect;
19
 
20
  #[derive(Debug, Clone, Copy)]
21
  pub struct RenderStrokeOptions {
koharu-renderer/src/text/latin.rs CHANGED
@@ -1,92 +1,19 @@
1
- use std::collections::{HashMap, VecDeque};
 
 
 
 
 
 
 
 
 
2
 
3
- use image::{GrayImage, Luma};
4
- use imageproc::{
5
- contrast::{ThresholdType, adaptive_threshold, otsu_level, threshold},
6
- distance_transform::Norm,
7
- morphology::{dilate, erode},
8
- region_labelling::{Connectivity, connected_components},
9
- };
10
- use koharu_core::TextBlock;
11
 
12
- use crate::layout::LayoutRun;
13
 
14
- pub const LATIN_OVERFLOW_FACTOR: f32 = 1.15;
15
- pub const LATIN_EXPANDED_OVERFLOW_FACTOR: f32 = 1.06;
16
- pub const LATIN_MIN_LEGIBLE_FONT_SIZE: f32 = 13.5;
17
- pub const LATIN_MIN_HEIGHT_FILL_RATIO: f32 = 0.55;
18
-
19
- const MIN_EXPANDABLE_BLOCK_SIZE_PX: i32 = 8;
20
-
21
- #[derive(Clone, Copy)]
22
- struct ExpandProfile {
23
- max_expand_x_factor: f32,
24
- max_expand_x_px: i32,
25
- max_expand_y_factor: f32,
26
- max_expand_y_px: i32,
27
- border_dark_scale: f32,
28
- border_dark_max: u8,
29
- border_barrier_radius: u8,
30
- border_max_area_factor: f32,
31
- border_min_seed_passable: f32,
32
- border_edge_min_density: f32,
33
- min_global_threshold: u8,
34
- adaptive_radius_divisor: f32,
35
- adaptive_delta: i32,
36
- close_radius: u8,
37
- open_radius: u8,
38
- edge_min_density: f32,
39
- min_area_gain: f32,
40
- max_width_factor: f32,
41
- max_height_factor: f32,
42
- min_component_fill: f32,
43
- }
44
-
45
- const STRICT_PROFILE: ExpandProfile = ExpandProfile {
46
- max_expand_x_factor: 0.34,
47
- max_expand_x_px: 88,
48
- max_expand_y_factor: 0.20,
49
- max_expand_y_px: 44,
50
- border_dark_scale: 0.58,
51
- border_dark_max: 116,
52
- border_barrier_radius: 2,
53
- border_max_area_factor: 12.0,
54
- border_min_seed_passable: 0.02,
55
- border_edge_min_density: 0.14,
56
- min_global_threshold: 150,
57
- adaptive_radius_divisor: 6.5,
58
- adaptive_delta: 6,
59
- close_radius: 2,
60
- open_radius: 1,
61
- edge_min_density: 0.62,
62
- min_area_gain: 1.08,
63
- max_width_factor: 2.7,
64
- max_height_factor: 2.2,
65
- min_component_fill: 0.4,
66
- };
67
-
68
- const RELAXED_PROFILE: ExpandProfile = ExpandProfile {
69
- max_expand_x_factor: 0.52,
70
- max_expand_x_px: 136,
71
- max_expand_y_factor: 0.30,
72
- max_expand_y_px: 64,
73
- border_dark_scale: 0.64,
74
- border_dark_max: 132,
75
- border_barrier_radius: 1,
76
- border_max_area_factor: 18.0,
77
- border_min_seed_passable: 0.015,
78
- border_edge_min_density: 0.10,
79
- min_global_threshold: 136,
80
- adaptive_radius_divisor: 8.0,
81
- adaptive_delta: 12,
82
- close_radius: 2,
83
- open_radius: 1,
84
- edge_min_density: 0.52,
85
- min_area_gain: 1.03,
86
- max_width_factor: 3.2,
87
- max_height_factor: 2.8,
88
- min_component_fill: 0.28,
89
- };
90
 
91
  #[derive(Clone, Copy, Debug, PartialEq)]
92
  pub struct LayoutBox {
@@ -96,114 +23,13 @@ pub struct LayoutBox {
96
  pub height: f32,
97
  }
98
 
99
- #[derive(Clone, Copy, Debug, PartialEq, Eq)]
100
- struct IntRect {
101
- x0: i32,
102
- y0: i32,
103
- x1: i32,
104
- y1: i32,
105
- }
106
-
107
- impl IntRect {
108
- fn new(x0: i32, y0: i32, x1: i32, y1: i32) -> Option<Self> {
109
- (x1 > x0 && y1 > y0).then_some(Self { x0, y0, x1, y1 })
110
- }
111
-
112
- fn from_layout_box(layout_box: LayoutBox) -> Self {
113
- Self {
114
- x0: layout_box.x.floor() as i32,
115
- y0: layout_box.y.floor() as i32,
116
- x1: (layout_box.x + layout_box.width).ceil() as i32,
117
- y1: (layout_box.y + layout_box.height).ceil() as i32,
118
- }
119
- }
120
-
121
- fn width(self) -> i32 {
122
- self.x1 - self.x0
123
- }
124
-
125
- fn height(self) -> i32 {
126
- self.y1 - self.y0
127
- }
128
-
129
- fn area(self) -> i32 {
130
- self.width().max(0) * self.height().max(0)
131
- }
132
-
133
- fn area_f32(self) -> f32 {
134
- self.area() as f32
135
- }
136
-
137
- fn to_layout_box(self) -> LayoutBox {
138
- LayoutBox {
139
- x: self.x0 as f32,
140
- y: self.y0 as f32,
141
- width: self.width() as f32,
142
- height: self.height() as f32,
143
- }
144
- }
145
-
146
- fn clamp_to(self, max_w: i32, max_h: i32) -> Option<Self> {
147
- let x0 = self.x0.clamp(0, max_w.saturating_sub(1));
148
- let y0 = self.y0.clamp(0, max_h.saturating_sub(1));
149
- let x1 = self.x1.clamp(x0 + 1, max_w);
150
- let y1 = self.y1.clamp(y0 + 1, max_h);
151
- Self::new(x0, y0, x1, y1)
152
- }
153
-
154
- fn expand(self, dx: i32, dy: i32, max_w: i32, max_h: i32) -> Self {
155
- Self {
156
- x0: (self.x0 - dx).max(0),
157
- y0: (self.y0 - dy).max(0),
158
- x1: (self.x1 + dx).min(max_w),
159
- y1: (self.y1 + dy).min(max_h),
160
- }
161
- }
162
-
163
- fn translate(self, dx: i32, dy: i32) -> Self {
164
- Self {
165
- x0: self.x0 + dx,
166
- y0: self.y0 + dy,
167
- x1: self.x1 + dx,
168
- y1: self.y1 + dy,
169
- }
170
- }
171
-
172
- fn union(self, other: Self) -> Self {
173
- Self {
174
- x0: self.x0.min(other.x0),
175
- y0: self.y0.min(other.y0),
176
- x1: self.x1.max(other.x1),
177
- y1: self.y1.max(other.y1),
178
- }
179
- }
180
-
181
- fn intersection_area(self, other: Self) -> i32 {
182
- let ix0 = self.x0.max(other.x0);
183
- let iy0 = self.y0.max(other.y0);
184
- let ix1 = self.x1.min(other.x1);
185
- let iy1 = self.y1.min(other.y1);
186
- (ix1 - ix0).max(0) * (iy1 - iy0).max(0)
187
- }
188
-
189
- fn clamp_expansion(
190
- self,
191
- original: Self,
192
- max_expand_x: i32,
193
- max_expand_y: i32,
194
- max_w: i32,
195
- max_h: i32,
196
- ) -> Self {
197
- Self {
198
- x0: self.x0.max(original.x0 - max_expand_x).max(0),
199
- y0: self.y0.max(original.y0 - max_expand_y).max(0),
200
- x1: self.x1.min(original.x1 + max_expand_x).min(max_w),
201
- y1: self.y1.min(original.y1 + max_expand_y).min(max_h),
202
- }
203
  }
204
  }
205
 
206
- pub fn layout_box_from_block(block: &TextBlock) -> LayoutBox {
207
  LayoutBox {
208
  x: block.x,
209
  y: block.y,
@@ -212,1081 +38,238 @@ pub fn layout_box_from_block(block: &TextBlock) -> LayoutBox {
212
  }
213
  }
214
 
215
- pub fn is_expanded_layout_box(layout_box: LayoutBox, original: LayoutBox) -> bool {
216
- layout_box.width > original.width * 1.05 || layout_box.height > original.height * 1.05
217
- }
218
-
219
- pub fn layout_box_area(layout_box: LayoutBox) -> f32 {
220
- layout_box.width.max(0.0) * layout_box.height.max(0.0)
221
- }
222
-
223
- pub fn latin_height_fill(layout: &LayoutRun<'_>, container_height: f32) -> f32 {
224
- if !container_height.is_finite() || container_height <= 0.0 {
225
- return 1.0;
226
- }
227
- (layout.height / container_height).clamp(0.0, 1.0)
228
- }
229
-
230
- pub fn latin_layout_underfilled(layout: &LayoutRun<'_>, container_height: f32) -> bool {
231
- layout.font_size < LATIN_MIN_LEGIBLE_FONT_SIZE
232
- || latin_height_fill(layout, container_height) < LATIN_MIN_HEIGHT_FILL_RATIO
233
- }
234
-
235
- pub fn latin_width_overflow_factor(expanded_box: bool, allow_expanded_overflow: bool) -> f32 {
236
- if expanded_box {
237
- if allow_expanded_overflow {
238
- LATIN_EXPANDED_OVERFLOW_FACTOR
239
- } else {
240
- 1.0
241
- }
242
- } else {
243
- LATIN_OVERFLOW_FACTOR
244
- }
245
- }
246
-
247
- pub fn pick_better_latin_candidate<'a>(
248
- current_layout: &LayoutRun<'a>,
249
- relaxed_candidate: Option<(LayoutRun<'a>, LayoutBox)>,
250
- overflow_candidate: Option<(LayoutRun<'a>, LayoutBox)>,
251
- ) -> Option<(LayoutRun<'a>, LayoutBox)> {
252
- let mut best: Option<(LayoutRun<'a>, LayoutBox)> = None;
253
-
254
- for candidate in [relaxed_candidate, overflow_candidate] {
255
- let Some((layout, layout_box)) = candidate else {
256
- continue;
257
- };
258
-
259
- if layout.font_size < current_layout.font_size + 0.25 {
260
- continue;
261
- }
262
-
263
- match &best {
264
- Some((best_layout, _)) if layout.font_size <= best_layout.font_size => {}
265
- _ => best = Some((layout, layout_box)),
266
- }
267
- }
268
-
269
- best
270
- }
271
-
272
- pub fn expand_latin_layout_box_strict(block: &TextBlock, bubble_map: &GrayImage) -> LayoutBox {
273
- expand_latin_layout_box_with_profile(block, bubble_map, STRICT_PROFILE)
274
- }
275
-
276
- pub fn expand_latin_layout_box_relaxed(block: &TextBlock, bubble_map: &GrayImage) -> LayoutBox {
277
- expand_latin_layout_box_with_profile(block, bubble_map, RELAXED_PROFILE)
278
- }
279
-
280
- fn expand_latin_layout_box_with_profile(
281
- block: &TextBlock,
282
- bubble_map: &GrayImage,
283
- profile: ExpandProfile,
284
- ) -> LayoutBox {
285
- let fallback = layout_box_from_block(block);
286
- let map_w = bubble_map.width() as i32;
287
- let map_h = bubble_map.height() as i32;
288
- if map_w <= 1 || map_h <= 1 {
289
- return fallback;
290
- }
291
-
292
- let Some(original) = clamped_bounds(fallback, map_w, map_h) else {
293
- return fallback;
294
- };
295
-
296
- let base_w = original.width();
297
- let base_h = original.height();
298
- if base_w < MIN_EXPANDABLE_BLOCK_SIZE_PX || base_h < MIN_EXPANDABLE_BLOCK_SIZE_PX {
299
- return fallback;
300
- }
301
-
302
- let max_expand_x = ((base_w as f32) * profile.max_expand_x_factor)
303
- .round()
304
- .clamp(8.0, profile.max_expand_x_px as f32) as i32;
305
- let max_expand_y = ((base_h as f32) * profile.max_expand_y_factor)
306
- .round()
307
- .clamp(4.0, profile.max_expand_y_px as f32) as i32;
308
-
309
- let roi_bounds = original.expand(max_expand_x, max_expand_y, map_w, map_h);
310
- let roi_w = roi_bounds.width() as usize;
311
- let roi_h = roi_bounds.height() as usize;
312
- if roi_w == 0 || roi_h == 0 {
313
- return fallback;
314
- }
315
- let roi = extract_roi_gray(
316
- bubble_map,
317
- roi_bounds.x0,
318
- roi_bounds.y0,
319
- roi_bounds.x1,
320
- roi_bounds.y1,
321
- );
322
- let seed = SeedRect {
323
- x0: (original.x0 - roi_bounds.x0).max(0),
324
- y0: (original.y0 - roi_bounds.y0).max(0),
325
- x1: (original.x1 - roi_bounds.x0).max(0),
326
- y1: (original.y1 - roi_bounds.y0).max(0),
327
- };
328
-
329
- if let Some((candidate_bounds, flooded_area)) =
330
- border_guided_expand_bounds(&roi, roi_bounds, seed, profile)
331
- && let Some(layout_box) =
332
- layout_box_from_candidate(candidate_bounds, original, flooded_area, profile)
333
- {
334
- return layout_box;
335
- }
336
-
337
- let global_threshold = otsu_level(&roi).max(profile.min_global_threshold);
338
- let global_bin = threshold(&roi, global_threshold, ThresholdType::Binary);
339
- let adaptive_radius = (((base_w.min(base_h) as f32) / profile.adaptive_radius_divisor)
340
- .round()
341
- .clamp(2.0, 18.0)) as u32;
342
- let adaptive_bin = adaptive_threshold(&roi, adaptive_radius, profile.adaptive_delta);
343
-
344
- let mut mask = intersect_binary_masks(&global_bin, &adaptive_bin);
345
- mask = morph_cleanup(mask, profile.close_radius, profile.open_radius);
346
- let labels = connected_components(&mask, Connectivity::Eight, Luma([0u8]));
347
-
348
- let Some(selected_component) = pick_best_component(&labels, seed) else {
349
- return fallback;
350
- };
351
- let component = selected_component.stats;
352
-
353
- let component_bounds = IntRect {
354
- x0: roi_bounds.x0 + component.min_x,
355
- y0: roi_bounds.y0 + component.min_y,
356
- x1: roi_bounds.x0 + component.max_x + 1,
357
- y1: roi_bounds.y0 + component.max_y + 1,
358
- };
359
- let overlap_area = original.intersection_area(component_bounds) as f32;
360
- let orig_area = original.area_f32();
361
- let component_area = component.area as f32;
362
- let overlap_ratio = if orig_area > 0.0 {
363
- overlap_area / orig_area
364
- } else {
365
- 0.0
366
- };
367
- let component_ratio = if orig_area > 0.0 {
368
- component_area / orig_area
369
- } else {
370
- 0.0
371
- };
372
- let seed_looks_oversized = component_ratio < 0.74 && overlap_ratio < 0.9;
373
-
374
- let mut expanded_bounds = if seed_looks_oversized {
375
- // Recover from previously over-expanded boxes by trusting the current bubble component.
376
- component_bounds
377
- } else {
378
- component_bounds.union(original)
379
- };
380
-
381
- expanded_bounds =
382
- expanded_bounds.clamp_expansion(original, max_expand_x, max_expand_y, map_w, map_h);
383
-
384
- expanded_bounds = tighten_expanded_bounds(
385
- &labels,
386
- selected_component.label,
387
- roi_bounds,
388
- expanded_bounds,
389
- original,
390
- profile.edge_min_density,
391
- );
392
-
393
- let out_w = expanded_bounds.width();
394
- let out_h = expanded_bounds.height();
395
- if out_w <= 0 || out_h <= 0 {
396
- return fallback;
397
- }
398
-
399
- let out_area = expanded_bounds.area_f32();
400
- if seed_looks_oversized {
401
- if out_area < orig_area * 0.24 {
402
- return fallback;
403
- }
404
- } else {
405
- if out_area < orig_area * profile.min_area_gain {
406
- return fallback;
407
- }
408
- if out_w as f32 > base_w as f32 * profile.max_width_factor
409
- || out_h as f32 > base_h as f32 * profile.max_height_factor
410
- {
411
- return fallback;
412
- }
413
- }
414
-
415
- let component_fill = component.area as f32 / out_area;
416
- if component_fill < profile.min_component_fill {
417
- return fallback;
418
- }
419
-
420
- expanded_bounds.to_layout_box()
421
- }
422
-
423
- #[derive(Clone, Copy)]
424
- struct SeedRect {
425
- x0: i32,
426
- y0: i32,
427
- x1: i32,
428
- y1: i32,
429
- }
430
-
431
- impl SeedRect {
432
- fn contains(self, x: i32, y: i32) -> bool {
433
- x >= self.x0 && x < self.x1 && y >= self.y0 && y < self.y1
434
- }
435
-
436
- fn center(self) -> (f32, f32) {
437
- (
438
- (self.x0 as f32 + (self.x1 - 1).max(self.x0) as f32) * 0.5,
439
- (self.y0 as f32 + (self.y1 - 1).max(self.y0) as f32) * 0.5,
440
- )
441
- }
442
- }
443
-
444
- #[derive(Clone, Copy, Debug)]
445
- struct ComponentStats {
446
- area: u32,
447
- overlap: u32,
448
- min_x: i32,
449
- min_y: i32,
450
- max_x: i32,
451
- max_y: i32,
452
- sum_x: u64,
453
- sum_y: u64,
454
- }
455
-
456
- impl ComponentStats {
457
- fn new(x: i32, y: i32) -> Self {
458
- Self {
459
- area: 0,
460
- overlap: 0,
461
- min_x: x,
462
- min_y: y,
463
- max_x: x,
464
- max_y: y,
465
- sum_x: 0,
466
- sum_y: 0,
467
- }
468
- }
469
-
470
- fn add_pixel(&mut self, x: i32, y: i32, overlaps_seed: bool) {
471
- self.area += 1;
472
- if overlaps_seed {
473
- self.overlap += 1;
474
- }
475
- self.min_x = self.min_x.min(x);
476
- self.min_y = self.min_y.min(y);
477
- self.max_x = self.max_x.max(x);
478
- self.max_y = self.max_y.max(y);
479
- self.sum_x += x as u64;
480
- self.sum_y += y as u64;
481
- }
482
-
483
- fn centroid(self) -> (f32, f32) {
484
- if self.area == 0 {
485
- return (self.min_x as f32, self.min_y as f32);
486
- }
487
- (
488
- self.sum_x as f32 / self.area as f32,
489
- self.sum_y as f32 / self.area as f32,
490
- )
491
- }
492
- }
493
-
494
- fn extract_roi_gray(map: &GrayImage, x0: i32, y0: i32, x1: i32, y1: i32) -> GrayImage {
495
- let w = (x1 - x0).max(1) as u32;
496
- let h = (y1 - y0).max(1) as u32;
497
- let mut roi = GrayImage::from_pixel(w, h, Luma([0u8]));
498
- for ry in 0..h {
499
- for rx in 0..w {
500
- let src_x = x0 + rx as i32;
501
- let src_y = y0 + ry as i32;
502
- roi.put_pixel(rx, ry, *map.get_pixel(src_x as u32, src_y as u32));
503
- }
504
- }
505
- roi
506
- }
507
-
508
- fn intersect_binary_masks(a: &GrayImage, b: &GrayImage) -> GrayImage {
509
- let w = a.width().min(b.width());
510
- let h = a.height().min(b.height());
511
- let mut out = GrayImage::from_pixel(w, h, Luma([0u8]));
512
- for y in 0..h {
513
- for x in 0..w {
514
- let on = a.get_pixel(x, y).0[0] > 0 && b.get_pixel(x, y).0[0] > 0;
515
- out.put_pixel(x, y, if on { Luma([255u8]) } else { Luma([0u8]) });
516
- }
517
- }
518
- out
519
- }
520
-
521
- fn morph_cleanup(mut mask: GrayImage, close_radius: u8, open_radius: u8) -> GrayImage {
522
- if close_radius > 0 {
523
- mask = dilate(&mask, Norm::LInf, close_radius);
524
- mask = erode(&mask, Norm::LInf, close_radius);
525
- }
526
- if open_radius > 0 {
527
- mask = erode(&mask, Norm::LInf, open_radius);
528
- mask = dilate(&mask, Norm::LInf, open_radius);
529
- }
530
- mask
531
- }
532
-
533
- #[derive(Clone, Copy)]
534
- enum EdgeSide {
535
- Left,
536
- Right,
537
- Top,
538
- Bottom,
539
- }
540
-
541
- #[derive(Clone, Copy, Debug)]
542
- struct SelectedComponent {
543
- label: u32,
544
- stats: ComponentStats,
545
- }
546
-
547
- fn tighten_expanded_bounds(
548
- labels: &image::ImageBuffer<Luma<u32>, Vec<u32>>,
549
- label: u32,
550
- roi_bounds: IntRect,
551
- bounds: IntRect,
552
- original: IntRect,
553
- min_edge_density: f32,
554
- ) -> IntRect {
555
- if min_edge_density <= 0.0 {
556
- return bounds;
557
- }
558
-
559
- let labels_w = labels.width() as i32;
560
- let labels_h = labels.height() as i32;
561
- if labels_w <= 1 || labels_h <= 1 {
562
- return bounds;
563
- }
564
-
565
- let mut roi_relative = bounds.translate(-roi_bounds.x0, -roi_bounds.y0);
566
- roi_relative = IntRect {
567
- x0: roi_relative.x0.clamp(0, labels_w.saturating_sub(1)),
568
- y0: roi_relative.y0.clamp(0, labels_h.saturating_sub(1)),
569
- x1: roi_relative.x1.clamp(roi_relative.x0 + 1, labels_w),
570
- y1: roi_relative.y1.clamp(roi_relative.y0 + 1, labels_h),
571
- };
572
-
573
- let original_relative = IntRect {
574
- x0: (original.x0 - roi_bounds.x0).clamp(0, labels_w.saturating_sub(1)),
575
- y0: (original.y0 - roi_bounds.y0).clamp(0, labels_h.saturating_sub(1)),
576
- x1: (original.x1 - roi_bounds.x0).clamp(0, labels_w),
577
- y1: (original.y1 - roi_bounds.y0).clamp(0, labels_h),
578
- };
579
-
580
- let strip = ((roi_relative.width().min(roi_relative.height()) as f32) * 0.06)
581
- .round()
582
- .clamp(1.0, 4.0) as i32;
583
-
584
- while roi_relative.x0 < original_relative.x0 {
585
- if edge_density(labels, label, roi_relative, EdgeSide::Left, strip) >= min_edge_density {
586
- break;
587
- }
588
- roi_relative.x0 += 1;
589
- }
590
- while roi_relative.x1 > original_relative.x1 {
591
- if edge_density(labels, label, roi_relative, EdgeSide::Right, strip) >= min_edge_density {
592
- break;
593
- }
594
- roi_relative.x1 -= 1;
595
- }
596
- while roi_relative.y0 < original_relative.y0 {
597
- if edge_density(labels, label, roi_relative, EdgeSide::Top, strip) >= min_edge_density {
598
- break;
599
- }
600
- roi_relative.y0 += 1;
601
- }
602
- while roi_relative.y1 > original_relative.y1 {
603
- if edge_density(labels, label, roi_relative, EdgeSide::Bottom, strip) >= min_edge_density {
604
- break;
605
- }
606
- roi_relative.y1 -= 1;
607
- }
608
-
609
- roi_relative.translate(roi_bounds.x0, roi_bounds.y0)
610
- }
611
-
612
- fn edge_density(
613
- labels: &image::ImageBuffer<Luma<u32>, Vec<u32>>,
614
- label: u32,
615
- bounds: IntRect,
616
- side: EdgeSide,
617
- strip: i32,
618
- ) -> f32 {
619
- if bounds.width() <= 0 || bounds.height() <= 0 {
620
- return 1.0;
621
- }
622
-
623
- let strip = strip.max(1);
624
- let (sx0, sy0, sx1, sy1) = match side {
625
- EdgeSide::Left => (
626
- bounds.x0,
627
- bounds.y0,
628
- (bounds.x0 + strip).min(bounds.x1),
629
- bounds.y1,
630
- ),
631
- EdgeSide::Right => (
632
- (bounds.x1 - strip).max(bounds.x0),
633
- bounds.y0,
634
- bounds.x1,
635
- bounds.y1,
636
- ),
637
- EdgeSide::Top => (
638
- bounds.x0,
639
- bounds.y0,
640
- bounds.x1,
641
- (bounds.y0 + strip).min(bounds.y1),
642
- ),
643
- EdgeSide::Bottom => (
644
- bounds.x0,
645
- (bounds.y1 - strip).max(bounds.y0),
646
- bounds.x1,
647
- bounds.y1,
648
- ),
649
- };
650
-
651
- let width = (sx1 - sx0).max(0);
652
- let height = (sy1 - sy0).max(0);
653
- let total = (width * height) as u32;
654
- if total == 0 {
655
- return 1.0;
656
- }
657
-
658
- let mut count = 0u32;
659
- for y in sy0..sy1 {
660
- for x in sx0..sx1 {
661
- if labels.get_pixel(x as u32, y as u32).0[0] == label {
662
- count += 1;
663
- }
664
- }
665
- }
666
- count as f32 / total as f32
667
- }
668
-
669
- fn border_guided_expand_bounds(
670
- roi: &GrayImage,
671
- roi_bounds: IntRect,
672
- seed: SeedRect,
673
- profile: ExpandProfile,
674
- ) -> Option<(IntRect, u32)> {
675
- let w = roi.width() as i32;
676
- let h = roi.height() as i32;
677
- if w <= 1 || h <= 1 {
678
- return None;
679
- }
680
-
681
- let sx0 = seed.x0.clamp(0, w.saturating_sub(1));
682
- let sy0 = seed.y0.clamp(0, h.saturating_sub(1));
683
- let sx1 = seed.x1.clamp(sx0 + 1, w);
684
- let sy1 = seed.y1.clamp(sy0 + 1, h);
685
- let seed_w = (sx1 - sx0).max(1);
686
- let seed_h = (sy1 - sy0).max(1);
687
- let seed_total = (seed_w * seed_h) as u32;
688
-
689
- let mut seed_sum = 0u64;
690
- let mut seed_count = 0u64;
691
- for y in sy0..sy1 {
692
- for x in sx0..sx1 {
693
- seed_sum += roi.get_pixel(x as u32, y as u32).0[0] as u64;
694
- seed_count += 1;
695
- }
696
- }
697
- if seed_count == 0 {
698
- return None;
699
- }
700
- let seed_mean = seed_sum as f32 / seed_count as f32;
701
- let dark_threshold = (seed_mean * profile.border_dark_scale)
702
- .round()
703
- .clamp(18.0, profile.border_dark_max as f32) as u8;
704
-
705
- let mut barrier = GrayImage::from_pixel(roi.width(), roi.height(), Luma([0u8]));
706
- for y in 0..roi.height() {
707
- for x in 0..roi.width() {
708
- let lum = roi.get_pixel(x, y).0[0];
709
- if lum <= dark_threshold {
710
- barrier.put_pixel(x, y, Luma([255u8]));
711
- }
712
- }
713
- }
714
- if profile.border_barrier_radius > 0 {
715
- barrier = dilate(&barrier, Norm::LInf, profile.border_barrier_radius);
716
- }
717
-
718
- let mut visited = vec![0u8; (w as usize) * (h as usize)];
719
- let mut queue: VecDeque<(i32, i32)> = VecDeque::new();
720
- let mut seed_passable = 0u32;
721
- for y in sy0..sy1 {
722
- for x in sx0..sx1 {
723
- if barrier.get_pixel(x as u32, y as u32).0[0] != 0 {
724
- continue;
725
- }
726
- seed_passable += 1;
727
- let idx = (y as usize) * (w as usize) + (x as usize);
728
- if visited[idx] == 0 {
729
- visited[idx] = 1;
730
- queue.push_back((x, y));
731
- }
732
- }
733
- }
734
-
735
- let seed_passable_ratio = seed_passable as f32 / seed_total.max(1) as f32;
736
- if seed_passable_ratio < profile.border_min_seed_passable {
737
- return None;
738
- }
739
-
740
- if queue.is_empty() {
741
- let cx = ((sx0 + sx1) / 2).clamp(0, w.saturating_sub(1));
742
- let cy = ((sy0 + sy1) / 2).clamp(0, h.saturating_sub(1));
743
- if barrier.get_pixel(cx as u32, cy as u32).0[0] != 0 {
744
- return None;
745
- }
746
- let idx = (cy as usize) * (w as usize) + (cx as usize);
747
- visited[idx] = 1;
748
- queue.push_back((cx, cy));
749
- }
750
-
751
- let max_area =
752
- ((seed_total as f32) * profile.border_max_area_factor).max(seed_total as f32 + 16.0) as u32;
753
- let mut area = 0u32;
754
- let mut min_x = w;
755
- let mut min_y = h;
756
- let mut max_x = 0i32;
757
- let mut max_y = 0i32;
758
-
759
- while let Some((x, y)) = queue.pop_front() {
760
- area += 1;
761
- if area > max_area {
762
- return None;
763
- }
764
- min_x = min_x.min(x);
765
- min_y = min_y.min(y);
766
- max_x = max_x.max(x);
767
- max_y = max_y.max(y);
768
-
769
- for ny in (y - 1)..=(y + 1) {
770
- for nx in (x - 1)..=(x + 1) {
771
- if nx == x && ny == y {
772
  continue;
773
  }
774
- if nx < 0 || ny < 0 || nx >= w || ny >= h {
775
- continue;
 
 
 
776
  }
777
- let idx = (ny as usize) * (w as usize) + (nx as usize);
778
- if visited[idx] != 0 {
779
- continue;
780
  }
781
- if barrier.get_pixel(nx as u32, ny as u32).0[0] != 0 {
782
- continue;
 
 
 
783
  }
784
- visited[idx] = 1;
785
- queue.push_back((nx, ny));
786
  }
787
  }
788
- }
789
-
790
- if area == 0 {
791
- return None;
792
- }
793
-
794
- let mut bx0 = min_x;
795
- let mut by0 = min_y;
796
- let mut bx1 = max_x + 1;
797
- let mut by1 = max_y + 1;
798
- tighten_flood_bounds(
799
- &visited,
800
- w,
801
- h,
802
- seed,
803
- &mut bx0,
804
- &mut by0,
805
- &mut bx1,
806
- &mut by1,
807
- profile.border_edge_min_density,
808
- );
809
- if bx1 <= bx0 || by1 <= by0 {
810
- return None;
811
- }
812
-
813
- Some((
814
- IntRect {
815
- x0: roi_bounds.x0 + bx0,
816
- y0: roi_bounds.y0 + by0,
817
- x1: roi_bounds.x0 + bx1,
818
- y1: roi_bounds.y0 + by1,
819
- },
820
- area,
821
- ))
822
- }
823
-
824
- fn layout_box_from_candidate(
825
- candidate_bounds: IntRect,
826
- original: IntRect,
827
- candidate_area: u32,
828
- profile: ExpandProfile,
829
- ) -> Option<LayoutBox> {
830
- let out_w = candidate_bounds.width();
831
- let out_h = candidate_bounds.height();
832
- if out_w <= 0 || out_h <= 0 {
833
- return None;
834
- }
835
-
836
- let orig_area = original.area_f32();
837
- let out_area = candidate_bounds.area_f32();
838
- if orig_area <= 0.0 || out_area <= 0.0 {
839
- return None;
840
- }
841
-
842
- let overlap_area = original.intersection_area(candidate_bounds) as f32;
843
- let overlap_ratio = overlap_area / orig_area;
844
- let candidate_ratio = candidate_area as f32 / orig_area;
845
- let seed_looks_oversized = candidate_ratio < 0.74 && overlap_ratio < 0.9;
846
-
847
- if seed_looks_oversized {
848
- if out_area < orig_area * 0.24 {
849
- return None;
850
- }
851
- } else {
852
- if out_area < orig_area * profile.min_area_gain {
853
- return None;
854
- }
855
- if out_w as f32 > original.width() as f32 * profile.max_width_factor
856
- || out_h as f32 > original.height() as f32 * profile.max_height_factor
857
- {
858
  return None;
859
  }
860
- }
861
-
862
- let fill = candidate_area as f32 / out_area;
863
- let min_fill = (profile.min_component_fill * 0.55).clamp(0.12, 0.9);
864
- if fill < min_fill {
865
- return None;
866
- }
867
-
868
- Some(candidate_bounds.to_layout_box())
869
- }
870
-
871
- #[allow(clippy::too_many_arguments)]
872
- fn tighten_flood_bounds(
873
- visited: &[u8],
874
- w: i32,
875
- h: i32,
876
- seed: SeedRect,
877
- x0: &mut i32,
878
- y0: &mut i32,
879
- x1: &mut i32,
880
- y1: &mut i32,
881
- min_edge_density: f32,
882
- ) {
883
- if min_edge_density <= 0.0 || *x1 <= *x0 || *y1 <= *y0 {
884
- return;
885
- }
886
 
887
- let sx0 = seed.x0.clamp(0, w.saturating_sub(1));
888
- let sy0 = seed.y0.clamp(0, h.saturating_sub(1));
889
- let sx1 = seed.x1.clamp(sx0 + 1, w);
890
- let sy1 = seed.y1.clamp(sy0 + 1, h);
891
- let strip = (((*x1 - *x0).min(*y1 - *y0) as f32) * 0.06)
892
- .round()
893
- .clamp(1.0, 4.0) as i32;
894
-
895
- while *x0 < sx0 {
896
- if flood_edge_density(
897
- visited,
898
- w,
899
- IntRect {
900
- x0: *x0,
901
- y0: *y0,
902
- x1: *x1,
903
- y1: *y1,
904
- },
905
- EdgeSide::Left,
906
- strip,
907
- ) >= min_edge_density
908
- {
909
- break;
910
- }
911
- *x0 += 1;
912
- if *x1 <= *x0 {
913
- return;
914
- }
915
- }
916
- while *x1 > sx1 {
917
- if flood_edge_density(
918
- visited,
919
- w,
920
- IntRect {
921
- x0: *x0,
922
- y0: *y0,
923
- x1: *x1,
924
- y1: *y1,
925
- },
926
- EdgeSide::Right,
927
- strip,
928
- ) >= min_edge_density
929
- {
930
- break;
931
- }
932
- *x1 -= 1;
933
- if *x1 <= *x0 {
934
- return;
935
- }
936
- }
937
- while *y0 < sy0 {
938
- if flood_edge_density(
939
- visited,
940
- w,
941
- IntRect {
942
- x0: *x0,
943
- y0: *y0,
944
- x1: *x1,
945
- y1: *y1,
946
- },
947
- EdgeSide::Top,
948
- strip,
949
- ) >= min_edge_density
950
- {
951
- break;
952
- }
953
- *y0 += 1;
954
- if *y1 <= *y0 {
955
- return;
956
- }
957
- }
958
- while *y1 > sy1 {
959
- if flood_edge_density(
960
- visited,
961
- w,
962
- IntRect {
963
- x0: *x0,
964
- y0: *y0,
965
- x1: *x1,
966
- y1: *y1,
967
- },
968
- EdgeSide::Bottom,
969
- strip,
970
- ) >= min_edge_density
971
- {
972
- break;
973
- }
974
- *y1 -= 1;
975
- if *y1 <= *y0 {
976
- return;
977
- }
978
- }
979
- }
980
-
981
- fn flood_edge_density(visited: &[u8], w: i32, bounds: IntRect, side: EdgeSide, strip: i32) -> f32 {
982
- if bounds.width() <= 0 || bounds.height() <= 0 || w <= 0 {
983
- return 1.0;
984
- }
985
- let strip = strip.max(1);
986
- let (sx0, sy0, sx1, sy1) = match side {
987
- EdgeSide::Left => (
988
- bounds.x0,
989
- bounds.y0,
990
- (bounds.x0 + strip).min(bounds.x1),
991
- bounds.y1,
992
- ),
993
- EdgeSide::Right => (
994
- (bounds.x1 - strip).max(bounds.x0),
995
- bounds.y0,
996
- bounds.x1,
997
- bounds.y1,
998
- ),
999
- EdgeSide::Top => (
1000
- bounds.x0,
1001
- bounds.y0,
1002
- bounds.x1,
1003
- (bounds.y0 + strip).min(bounds.y1),
1004
- ),
1005
- EdgeSide::Bottom => (
1006
- bounds.x0,
1007
- (bounds.y1 - strip).max(bounds.y0),
1008
- bounds.x1,
1009
- bounds.y1,
1010
- ),
1011
- };
1012
- let width = (sx1 - sx0).max(0);
1013
- let height = (sy1 - sy0).max(0);
1014
- let total = (width * height) as u32;
1015
- if total == 0 {
1016
- return 1.0;
1017
- }
1018
-
1019
- let mut count = 0u32;
1020
- for y in sy0..sy1 {
1021
- for x in sx0..sx1 {
1022
- let idx = (y as usize) * (w as usize) + (x as usize);
1023
- if visited.get(idx).copied().unwrap_or(0) != 0 {
1024
- count += 1;
1025
  }
1026
  }
1027
- }
1028
- count as f32 / total as f32
1029
- }
1030
 
1031
- fn pick_best_component(
1032
- labels: &image::ImageBuffer<Luma<u32>, Vec<u32>>,
1033
- seed: SeedRect,
1034
- ) -> Option<SelectedComponent> {
1035
- let mut by_label: HashMap<u32, ComponentStats> = HashMap::new();
1036
- for y in 0..labels.height() as i32 {
1037
- for x in 0..labels.width() as i32 {
1038
- let label = labels.get_pixel(x as u32, y as u32).0[0];
1039
- if label == 0 {
1040
- continue;
1041
- }
1042
- let entry = by_label
1043
- .entry(label)
1044
- .or_insert_with(|| ComponentStats::new(x, y));
1045
- entry.add_pixel(x, y, seed.contains(x, y));
1046
- }
1047
  }
1048
- if by_label.is_empty() {
1049
- return None;
1050
- }
1051
-
1052
- let (seed_cx, seed_cy) = seed.center();
1053
- let mut best: Option<(f32, SelectedComponent)> = None;
1054
- for (&label, &component) in &by_label {
1055
- let (cx, cy) = component.centroid();
1056
- let dist2 = (cx - seed_cx).powi(2) + (cy - seed_cy).powi(2);
1057
- let score = if component.overlap > 0 {
1058
- component.overlap as f32 * 10_000.0 + component.area as f32 - dist2 * 0.1
1059
- } else {
1060
- component.area as f32 - dist2 * 0.35
1061
- };
1062
- match best {
1063
- Some((best_score, _)) if score <= best_score => {}
1064
- _ => {
1065
- best = Some((
1066
- score,
1067
- SelectedComponent {
1068
- label,
1069
- stats: component,
1070
- },
1071
- ))
1072
- }
1073
- }
1074
- }
1075
-
1076
- best.map(|(_, component)| component)
1077
- }
1078
-
1079
- fn clamped_bounds(layout_box: LayoutBox, map_w: i32, map_h: i32) -> Option<IntRect> {
1080
- IntRect::from_layout_box(layout_box).clamp_to(map_w, map_h)
1081
  }
1082
 
1083
  #[cfg(test)]
1084
  mod tests {
1085
- use image::{GrayImage, Luma};
1086
-
1087
- use super::{
1088
- LATIN_OVERFLOW_FACTOR, LayoutBox, TextBlock, expand_latin_layout_box_relaxed,
1089
- expand_latin_layout_box_strict, is_expanded_layout_box, latin_width_overflow_factor,
1090
- layout_box_area,
1091
- };
1092
 
1093
- fn synthetic_bubble_map() -> GrayImage {
1094
- let mut img = GrayImage::from_pixel(96, 96, Luma([38]));
1095
- for y in 20..76 {
1096
- for x in 20..76 {
1097
- img.put_pixel(x, y, Luma([232]));
1098
- }
1099
- }
1100
- // dark bubble border
1101
- for x in 20..76 {
1102
- img.put_pixel(x, 20, Luma([26]));
1103
- img.put_pixel(x, 75, Luma([26]));
1104
- }
1105
- for y in 20..76 {
1106
- img.put_pixel(20, y, Luma([26]));
1107
- img.put_pixel(75, y, Luma([26]));
1108
- }
1109
- // text-like dark noise inside
1110
- for y in (28..68).step_by(6) {
1111
- for x in 34..62 {
1112
- img.put_pixel(x, y, Luma([60]));
1113
  }
1114
  }
1115
- img
1116
- }
1117
-
1118
- fn synthetic_bubble_with_thin_tail() -> GrayImage {
1119
- let mut img = GrayImage::from_pixel(128, 96, Luma([34]));
1120
- for y in 20..76 {
1121
- for x in 20..84 {
1122
- img.put_pixel(x, y, Luma([232]));
1123
- }
1124
- }
1125
- // open thin tail to the right, connected to the main bubble
1126
- for y in 44..49 {
1127
- for x in 84..112 {
1128
- img.put_pixel(x, y, Luma([232]));
1129
- }
1130
- }
1131
- // inner dark text-like strokes
1132
- for y in (30..68).step_by(7) {
1133
- for x in 34..72 {
1134
- img.put_pixel(x, y, Luma([58]));
1135
- }
1136
- }
1137
- img
1138
- }
1139
-
1140
- fn synthetic_ellipse_bubble_map() -> GrayImage {
1141
- let w = 148u32;
1142
- let h = 136u32;
1143
- let mut img = GrayImage::from_pixel(w, h, Luma([214]));
1144
- let cx = 76.0f32;
1145
- let cy = 68.0f32;
1146
- let rx = 20.0f32;
1147
- let ry = 48.0f32;
1148
-
1149
- for y in 0..h {
1150
- for x in 0..w {
1151
- let nx = (x as f32 - cx) / rx;
1152
- let ny = (y as f32 - cy) / ry;
1153
- let d = nx * nx + ny * ny;
1154
- if d <= 1.0 {
1155
- img.put_pixel(x, y, Luma([238]));
1156
- }
1157
- if (d - 1.0).abs() <= 0.045 {
1158
- img.put_pixel(x, y, Luma([18]));
1159
- }
1160
- }
1161
- }
1162
-
1163
- // Halftone-like outside noise.
1164
- for y in 0..h {
1165
- for x in 0..w {
1166
- if img.get_pixel(x, y).0[0] >= 230 && ((x + 2 * y) % 9 == 0) {
1167
- img.put_pixel(x, y, Luma([222]));
1168
- }
1169
- }
1170
- }
1171
- img
1172
- }
1173
-
1174
- #[test]
1175
- fn strict_expansion_grows_without_crossing_border() {
1176
- let map = synthetic_bubble_map();
1177
- let block = TextBlock {
1178
- x: 38.0,
1179
- y: 36.0,
1180
- width: 12.0,
1181
- height: 10.0,
1182
- ..Default::default()
1183
- };
1184
- let expanded = expand_latin_layout_box_strict(&block, &map);
1185
-
1186
- assert!(expanded.width > block.width);
1187
- assert!(expanded.height > block.height);
1188
- assert!(expanded.x >= 20.0);
1189
- assert!(expanded.y >= 20.0);
1190
- assert!(expanded.x + expanded.width <= 76.0);
1191
- assert!(expanded.y + expanded.height <= 76.0);
1192
  }
1193
 
1194
  #[test]
1195
- fn relaxed_candidate_not_smaller_than_strict_in_area() {
1196
- let map = synthetic_bubble_map();
1197
- let block = TextBlock {
1198
- x: 38.0,
1199
- y: 36.0,
1200
- width: 12.0,
1201
- height: 10.0,
1202
- ..Default::default()
1203
  };
1204
- let strict = expand_latin_layout_box_strict(&block, &map);
1205
- let relaxed = expand_latin_layout_box_relaxed(&block, &map);
1206
- assert!(layout_box_area(relaxed) >= layout_box_area(strict));
1207
- }
1208
-
1209
- #[test]
1210
- fn overflow_factor_respects_expanded_state() {
1211
  assert_eq!(
1212
- latin_width_overflow_factor(false, false),
1213
- LATIN_OVERFLOW_FACTOR
 
 
 
 
 
1214
  );
1215
- assert_eq!(latin_width_overflow_factor(true, false), 1.0);
1216
- assert!(latin_width_overflow_factor(true, true) > 1.0);
1217
  }
1218
 
1219
  #[test]
1220
- fn expanded_layout_box_detection_works() {
1221
- let original = LayoutBox {
1222
- x: 10.0,
1223
- y: 10.0,
1224
- width: 100.0,
1225
- height: 60.0,
1226
- };
1227
- let expanded = LayoutBox {
1228
- x: 8.0,
1229
- y: 8.0,
1230
- width: 108.0,
1231
- height: 64.0,
1232
  };
1233
- assert!(is_expanded_layout_box(expanded, original));
 
 
 
 
 
 
 
 
 
 
 
1234
  }
1235
 
1236
  #[test]
1237
- fn strict_expansion_avoids_thin_tail_overflow() {
1238
- let map = synthetic_bubble_with_thin_tail();
1239
- let block = TextBlock {
1240
- x: 36.0,
1241
- y: 34.0,
1242
- width: 40.0,
1243
- height: 22.0,
1244
- ..Default::default()
 
 
 
 
 
1245
  };
1246
- let expanded = expand_latin_layout_box_strict(&block, &map);
1247
-
1248
- // Thin protrusions should not drag the box far to the right.
1249
- assert!(expanded.x + expanded.width <= 86.0);
1250
- assert!(expanded.width > block.width);
1251
  }
1252
 
1253
  #[test]
1254
- fn strict_expansion_can_shrink_oversized_seed_box() {
1255
- let map = synthetic_bubble_map();
1256
- let block = TextBlock {
1257
- x: 8.0,
1258
- y: 8.0,
1259
- width: 74.0,
1260
- height: 70.0,
1261
- ..Default::default()
 
 
1262
  };
1263
- let adjusted = expand_latin_layout_box_strict(&block, &map);
1264
-
1265
- assert!(adjusted.width < block.width);
1266
- assert!(adjusted.height < block.height);
1267
- assert!(adjusted.x >= 20.0);
1268
- assert!(adjusted.y >= 20.0);
1269
- assert!(adjusted.x + adjusted.width <= 76.0);
1270
- assert!(adjusted.y + adjusted.height <= 76.0);
1271
  }
1272
 
1273
  #[test]
1274
- fn strict_expansion_stops_at_ellipse_border() {
1275
- let map = synthetic_ellipse_bubble_map();
1276
- let block = TextBlock {
1277
- x: 71.0,
1278
- y: 44.0,
1279
- width: 10.0,
1280
- height: 34.0,
1281
- ..Default::default()
 
 
 
 
 
 
 
1282
  };
1283
- let expanded = expand_latin_layout_box_strict(&block, &map);
1284
-
1285
- assert!(expanded.width > block.width);
1286
- assert!(expanded.height > block.height);
1287
- assert!(expanded.x >= 55.0);
1288
- assert!(expanded.y >= 20.0);
1289
- assert!(expanded.x + expanded.width <= 97.0);
1290
- assert!(expanded.y + expanded.height <= 117.0);
1291
  }
1292
  }
 
1
+ //! Latin text layout helpers.
2
+ //!
3
+ //! Two pieces of public API:
4
+ //! - [`LayoutBox`] + [`layout_box_from_block`]: the axis-aligned box a text
5
+ //! block lays out into. The layout engine treats this as a hard boundary.
6
+ //! - [`bubble_bbox_for_seed`]: given the bubble-segmentation mask (where
7
+ //! each detected bubble is painted with a unique non-zero grayscale ID)
8
+ //! and a text seed's bbox, pick the bubble that contains the seed and
9
+ //! return that bubble's bbox — this is exactly the region the model
10
+ //! detected, so it's the correct layout rect for the seed's text.
11
 
12
+ use std::collections::HashMap;
 
 
 
 
 
 
 
13
 
14
+ use image::GrayImage;
15
 
16
+ use crate::types::RenderBlock;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  #[derive(Clone, Copy, Debug, PartialEq)]
19
  pub struct LayoutBox {
 
23
  pub height: f32,
24
  }
25
 
26
+ impl LayoutBox {
27
+ pub fn area(self) -> f32 {
28
+ self.width.max(0.0) * self.height.max(0.0)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  }
30
  }
31
 
32
+ pub fn layout_box_from_block(block: &RenderBlock) -> LayoutBox {
33
  LayoutBox {
34
  x: block.x,
35
  y: block.y,
 
38
  }
39
  }
40
 
41
+ /// Fraction of each axis trimmed off the bubble's bounding box to keep
42
+ /// text comfortably inside the balloon. 12% per side ~76% of the
43
+ /// detected bbox is usable layout space — leaves breathing room around
44
+ /// the text instead of pushing lettering flush against the bubble
45
+ /// outline, and absorbs slight bbox imprecision from the segmenter.
46
+ const BUBBLE_INSET_FRAC: f32 = 0.12;
47
+
48
+ /// Pre-built index over a bubble-segmentation mask.
49
+ ///
50
+ /// The mask encodes one bubble per non-zero grayscale value (IDs 1..=N).
51
+ /// This index scans the mask once to extract each ID's bounding box, so
52
+ /// repeated [`BubbleIndex::lookup`] calls on successive text seeds cost
53
+ /// only an O(seed_bbox_area) pixel-count to find the seed's majority ID.
54
+ pub struct BubbleIndex {
55
+ mask: GrayImage,
56
+ bboxes: HashMap<u8, LayoutBox>,
57
+ }
58
+
59
+ impl BubbleIndex {
60
+ /// Build the index. `mask` must have each detected bubble painted
61
+ /// with a unique non-zero u8 ID (0 = outside any bubble).
62
+ pub fn new(mask: GrayImage) -> Self {
63
+ let w = mask.width();
64
+ let h = mask.height();
65
+ // Accumulator: id → (min_x, min_y, max_x, max_y).
66
+ let mut extents: HashMap<u8, [i32; 4]> = HashMap::new();
67
+ for y in 0..h {
68
+ for x in 0..w {
69
+ let id = mask.get_pixel(x, y).0[0];
70
+ if id == 0 {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  continue;
72
  }
73
+ let e = extents
74
+ .entry(id)
75
+ .or_insert([x as i32, y as i32, x as i32, y as i32]);
76
+ if (x as i32) < e[0] {
77
+ e[0] = x as i32;
78
  }
79
+ if (y as i32) < e[1] {
80
+ e[1] = y as i32;
 
81
  }
82
+ if (x as i32) > e[2] {
83
+ e[2] = x as i32;
84
+ }
85
+ if (y as i32) > e[3] {
86
+ e[3] = y as i32;
87
  }
 
 
88
  }
89
  }
90
+ let bboxes = extents
91
+ .into_iter()
92
+ .map(|(id, e)| {
93
+ (
94
+ id,
95
+ LayoutBox {
96
+ x: e[0] as f32,
97
+ y: e[1] as f32,
98
+ width: (e[2] - e[0] + 1) as f32,
99
+ height: (e[3] - e[1] + 1) as f32,
100
+ },
101
+ )
102
+ })
103
+ .collect();
104
+ Self { mask, bboxes }
105
+ }
106
+
107
+ /// Find the bubble this text seed belongs to and return its layout rect.
108
+ ///
109
+ /// Assignment rule: count pixels under the seed bbox labelled with each
110
+ /// ID; pick the ID with the most coverage. This handles seeds that
111
+ /// slightly overshoot the detected bubble edge, and overlapping bubble
112
+ /// bboxes (a nested small bubble always wins against an enclosing large
113
+ /// one because the small bubble's ID is what overwrites the overlap
114
+ /// region when the mask is painted smaller-last).
115
+ ///
116
+ /// Returns `None` when the seed bbox has zero coverage over any bubble.
117
+ pub fn lookup(&self, seed: LayoutBox) -> Option<LayoutBox> {
118
+ let w = self.mask.width() as i32;
119
+ let h = self.mask.height() as i32;
120
+ if w <= 0 || h <= 0 {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  return None;
122
  }
123
+ let sx0 = (seed.x.floor() as i32).max(0).min(w - 1);
124
+ let sy0 = (seed.y.floor() as i32).max(0).min(h - 1);
125
+ let sx1 = ((seed.x + seed.width).ceil() as i32).clamp(sx0 + 1, w);
126
+ let sy1 = ((seed.y + seed.height).ceil() as i32).clamp(sy0 + 1, h);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
 
128
+ let mut counts: HashMap<u8, u32> = HashMap::new();
129
+ for y in sy0..sy1 {
130
+ for x in sx0..sx1 {
131
+ let id = self.mask.get_pixel(x as u32, y as u32).0[0];
132
+ if id == 0 {
133
+ continue;
134
+ }
135
+ *counts.entry(id).or_insert(0) += 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  }
137
  }
138
+ let (best_id, _) = counts.into_iter().max_by_key(|&(_, c)| c)?;
139
+ let bbox = self.bboxes.get(&best_id)?;
 
140
 
141
+ let inset_x = bbox.width * BUBBLE_INSET_FRAC;
142
+ let inset_y = bbox.height * BUBBLE_INSET_FRAC;
143
+ let width = (bbox.width - 2.0 * inset_x).max(1.0);
144
+ let height = (bbox.height - 2.0 * inset_y).max(1.0);
145
+ Some(LayoutBox {
146
+ x: bbox.x + inset_x,
147
+ y: bbox.y + inset_y,
148
+ width,
149
+ height,
150
+ })
 
 
 
 
 
 
151
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  }
153
 
154
  #[cfg(test)]
155
  mod tests {
156
+ use super::*;
157
+ use image::Luma;
 
 
 
 
 
158
 
159
+ fn paint_rect(img: &mut GrayImage, x0: u32, y0: u32, x1: u32, y1: u32, value: u8) {
160
+ for y in y0..y1 {
161
+ for x in x0..x1 {
162
+ img.put_pixel(x, y, Luma([value]));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  }
164
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  }
166
 
167
  #[test]
168
+ fn layout_box_from_block_preserves_rect() {
169
+ let block = RenderBlock {
170
+ x: 12.0,
171
+ y: 18.0,
172
+ width: 40.0,
173
+ height: 20.0,
174
+ text: "hello".into(),
 
175
  };
 
 
 
 
 
 
 
176
  assert_eq!(
177
+ layout_box_from_block(&block),
178
+ LayoutBox {
179
+ x: 12.0,
180
+ y: 18.0,
181
+ width: 40.0,
182
+ height: 20.0,
183
+ }
184
  );
 
 
185
  }
186
 
187
  #[test]
188
+ fn lookup_returns_bubble_bbox_for_seed_inside() {
189
+ // One bubble painted at x=20..80, y=30..100 with ID 1.
190
+ let mut mask = GrayImage::from_pixel(200, 200, Luma([0u8]));
191
+ paint_rect(&mut mask, 20, 30, 80, 100, 1);
192
+ let index = BubbleIndex::new(mask);
193
+
194
+ let seed = LayoutBox {
195
+ x: 40.0,
196
+ y: 50.0,
197
+ width: 10.0,
198
+ height: 10.0,
 
199
  };
200
+ let rect = index.lookup(seed).expect("should find bubble");
201
+ // The returned rect is the bubble bbox with a small inset.
202
+ assert!(rect.x >= 20.0);
203
+ assert!(rect.y >= 30.0);
204
+ assert!(rect.x + rect.width <= 80.0);
205
+ assert!(rect.y + rect.height <= 100.0);
206
+ // Padded inset reserves a comfortable margin on each side, so the
207
+ // usable area is a meaningful fraction of the bubble but not the
208
+ // whole thing.
209
+ let coverage = rect.area() / ((80.0 - 20.0) * (100.0 - 30.0));
210
+ assert!(coverage > 0.5);
211
+ assert!(coverage < 0.95);
212
  }
213
 
214
  #[test]
215
+ fn lookup_picks_seed_majority_id_when_two_bubbles_overlap_seed() {
216
+ // Two bubbles. Seed straddles them but is mostly in bubble 2.
217
+ let mut mask = GrayImage::from_pixel(200, 200, Luma([0u8]));
218
+ paint_rect(&mut mask, 0, 0, 100, 200, 1); // bubble 1 (left half)
219
+ paint_rect(&mut mask, 100, 0, 200, 200, 2); // bubble 2 (right half)
220
+ let index = BubbleIndex::new(mask);
221
+
222
+ // Seed mostly in bubble 2 (x ∈ 95..130, 5 px into bubble 1, 30 into bubble 2).
223
+ let seed = LayoutBox {
224
+ x: 95.0,
225
+ y: 90.0,
226
+ width: 35.0,
227
+ height: 20.0,
228
  };
229
+ let rect = index.lookup(seed).expect("should find bubble");
230
+ // Expected: bubble 2's bbox (x ∈ 100..199).
231
+ assert!(rect.x >= 100.0);
 
 
232
  }
233
 
234
  #[test]
235
+ fn lookup_returns_none_when_seed_outside_any_bubble() {
236
+ let mut mask = GrayImage::from_pixel(200, 200, Luma([0u8]));
237
+ paint_rect(&mut mask, 10, 10, 50, 50, 1);
238
+ let index = BubbleIndex::new(mask);
239
+
240
+ let seed = LayoutBox {
241
+ x: 120.0,
242
+ y: 120.0,
243
+ width: 20.0,
244
+ height: 20.0,
245
  };
246
+ assert!(index.lookup(seed).is_none());
 
 
 
 
 
 
 
247
  }
248
 
249
  #[test]
250
+ fn nested_bubble_wins_over_enclosing_one() {
251
+ // Big bubble (ID 1) with small bubble (ID 2) painted inside it.
252
+ // The engine's paint-smaller-last rule makes this work — we
253
+ // simulate by painting ID 2 on top of ID 1.
254
+ let mut mask = GrayImage::from_pixel(200, 200, Luma([0u8]));
255
+ paint_rect(&mut mask, 10, 10, 190, 190, 1);
256
+ paint_rect(&mut mask, 80, 80, 120, 120, 2);
257
+ let index = BubbleIndex::new(mask);
258
+
259
+ // Seed entirely inside the small bubble.
260
+ let seed = LayoutBox {
261
+ x: 90.0,
262
+ y: 90.0,
263
+ width: 20.0,
264
+ height: 20.0,
265
  };
266
+ let rect = index.lookup(seed).expect("should find small bubble");
267
+ // Small bubble bbox is roughly [80, 80] to [120, 120] → the
268
+ // returned rect should be nested inside these bounds, not the
269
+ // enclosing big bubble.
270
+ assert!(rect.x >= 80.0);
271
+ assert!(rect.y >= 80.0);
272
+ assert!(rect.x + rect.width <= 120.0);
273
+ assert!(rect.y + rect.height <= 120.0);
274
  }
275
  }
koharu-renderer/src/text/script.rs CHANGED
@@ -1,16 +1,14 @@
1
  use harfrust::{Direction, Script, Tag};
2
  use icu::properties::{CodePointMapData, props::Script as IcuScript};
3
- use koharu_core::TextBlock;
4
 
5
  use crate::layout::WritingMode;
 
6
 
7
- pub fn writing_mode_for_block(text_block: &TextBlock) -> WritingMode {
8
- let text = match &text_block.translation {
9
- Some(t) => t,
10
- None => return WritingMode::Horizontal,
11
- };
12
-
13
- if !is_cjk_text(text) || text_block.width >= text_block.height {
14
  WritingMode::Horizontal
15
  } else {
16
  WritingMode::VerticalRl
@@ -211,9 +209,8 @@ fn is_cjk_text(text: &str) -> bool {
211
 
212
  #[cfg(test)]
213
  mod tests {
214
- use koharu_core::{TextBlock, TextDirection};
215
-
216
  use crate::layout::WritingMode;
 
217
 
218
  use super::{
219
  font_families_for_text, is_latin_only, normalize_translation_for_layout,
@@ -242,10 +239,10 @@ mod tests {
242
 
243
  #[test]
244
  fn writing_mode_uses_cjk_tall_box_heuristic() {
245
- let block = TextBlock {
246
  width: 40.0,
247
  height: 120.0,
248
- translation: Some("縦書き".to_string()),
249
  ..Default::default()
250
  };
251
 
@@ -253,13 +250,11 @@ mod tests {
253
  }
254
 
255
  #[test]
256
- fn writing_mode_ignores_stale_rendered_direction() {
257
- let block = TextBlock {
258
  width: 40.0,
259
  height: 120.0,
260
- translation: Some("HELLO".to_string()),
261
- source_direction: Some(TextDirection::Horizontal),
262
- rendered_direction: Some(TextDirection::Vertical),
263
  ..Default::default()
264
  };
265
 
 
1
  use harfrust::{Direction, Script, Tag};
2
  use icu::properties::{CodePointMapData, props::Script as IcuScript};
 
3
 
4
  use crate::layout::WritingMode;
5
+ use crate::types::RenderBlock;
6
 
7
+ pub fn writing_mode_for_block(block: &RenderBlock) -> WritingMode {
8
+ if block.text.is_empty() {
9
+ return WritingMode::Horizontal;
10
+ }
11
+ if !is_cjk_text(&block.text) || block.width >= block.height {
 
 
12
  WritingMode::Horizontal
13
  } else {
14
  WritingMode::VerticalRl
 
209
 
210
  #[cfg(test)]
211
  mod tests {
 
 
212
  use crate::layout::WritingMode;
213
+ use crate::types::RenderBlock;
214
 
215
  use super::{
216
  font_families_for_text, is_latin_only, normalize_translation_for_layout,
 
239
 
240
  #[test]
241
  fn writing_mode_uses_cjk_tall_box_heuristic() {
242
+ let block = RenderBlock {
243
  width: 40.0,
244
  height: 120.0,
245
+ text: "縦書き".to_string(),
246
  ..Default::default()
247
  };
248
 
 
250
  }
251
 
252
  #[test]
253
+ fn writing_mode_uses_latin_text_even_in_tall_box() {
254
+ let block = RenderBlock {
255
  width: 40.0,
256
  height: 120.0,
257
+ text: "HELLO".to_string(),
 
 
258
  ..Default::default()
259
  };
260
 
koharu-renderer/src/types.rs ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Self-contained primitives for the rendering API.
2
+ //!
3
+ //! The app layer (`koharu-app`) translates scene `TextStyle` / `TextShaderEffect`
4
+ //! values into these before calling the renderer.
5
+
6
+ /// Horizontal alignment within a text layout box.
7
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8
+ pub enum TextAlign {
9
+ #[default]
10
+ Left,
11
+ Center,
12
+ Right,
13
+ }
14
+
15
+ /// Simple shader effect flags applied to glyph rendering.
16
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17
+ pub struct TextShaderEffect {
18
+ pub italic: bool,
19
+ pub bold: bool,
20
+ }
21
+
22
+ impl TextShaderEffect {
23
+ pub fn none() -> Self {
24
+ Self::default()
25
+ }
26
+
27
+ pub fn is_empty(self) -> bool {
28
+ !self.italic && !self.bold
29
+ }
30
+ }
31
+
32
+ /// Reading axis hint for a block of text.
33
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34
+ pub enum TextDirection {
35
+ Horizontal,
36
+ Vertical,
37
+ }
38
+
39
+ /// A single text block staged for rendering. Callers (i.e. `koharu-app`) translate
40
+ /// scene `TextData` nodes into these and hand a slice to the renderer.
41
+ ///
42
+ /// `text` is the string to render (typically the translation). Empty-text blocks
43
+ /// should be filtered out by the caller; the renderer assumes `text` is non-empty.
44
+ #[derive(Debug, Clone, Default)]
45
+ pub struct RenderBlock {
46
+ pub x: f32,
47
+ pub y: f32,
48
+ pub width: f32,
49
+ pub height: f32,
50
+ pub text: String,
51
+ }