Mayo commited on
Commit
b89b943
·
unverified ·
1 Parent(s): 4206727

feat: better text rendering

Browse files
koharu-app/src/pipeline/engines/bubble_segmentation.rs CHANGED
@@ -1,25 +1,13 @@
1
- //! Speech-bubble segmentation. Produces a `Mask { Bubble }` layer where
2
- //! pixels inside any detected speech balloon are `255` and everything else
3
- //! is `0`. The renderer consumes this mask to grow per-block text layout
4
- //! boxes inside the bubble so English translations wrap into the full
5
- //! available space instead of being shrunk to fit the detector's
6
- //! (Japanese-shaped) source bbox.
7
- //!
8
- //! We build the mask from the model's **detection bboxes** (one per
9
- //! detected bubble), not from its per-pixel probability map. The model's
10
- //! bbox head is consistently accurate while its mask head under-segments
11
- //! bubble interiors — tight, text-shaped blobs that don't cover the full
12
- //! balloon. A text rect computed from the under-segmented mask would land
13
- //! wherever that small blob is (often off-centre in the actual bubble),
14
- //! which is exactly the "text outside the bubble" artefact. Bbox-filled
15
- //! rectangles trade a tiny bit of accuracy in the balloon's curved corners
16
- //! (handled by the renderer's inset) for guaranteed full coverage.
17
 
18
  use anyhow::Result;
19
  use async_trait::async_trait;
20
  use image::{DynamicImage, GrayImage, Luma};
21
  use koharu_core::{MaskRole, Op};
22
- use koharu_ml::speech_bubble_segmentation::SpeechBubbleSegmentation;
23
 
24
  use crate::pipeline::artifacts::Artifact;
25
  use crate::pipeline::engine::{Engine, EngineCtx, EngineInfo};
@@ -33,36 +21,9 @@ impl Engine for Model {
33
  let image = load_source_image(ctx.scene, ctx.page, ctx.blobs)?;
34
  let result = self.0.inference(&image)?;
35
 
36
- let w = result.image_width;
37
- let h = result.image_height;
38
- let mut mask = GrayImage::from_pixel(w, h, Luma([0u8]));
39
-
40
- // Each detected bubble gets a unique non-zero grayscale ID in the
41
- // mask. The renderer reads the ID under a text seed to recover
42
- // exactly which bubble bbox that seed belongs to — no flood fill,
43
- // no CC merging, no partition heuristics. Overlapping bboxes stay
44
- // separable because smaller bubbles overwrite larger ones (painted
45
- // last, after sorting by descending area), so a text seed inside
46
- // a nested or embedded small bubble reads the small bubble's ID.
47
- //
48
- // Cap at 255 IDs; typical manga pages have well under 20 bubbles.
49
  let mut regions: Vec<_> = result.regions.iter().collect();
50
  regions.sort_by_key(|region| std::cmp::Reverse(region.area));
51
- for (i, region) in regions.iter().take(255).enumerate() {
52
- let id = (i + 1) as u8;
53
- let x0 = region.bbox[0].floor().max(0.0) as u32;
54
- let y0 = region.bbox[1].floor().max(0.0) as u32;
55
- let x1 = (region.bbox[2].ceil().max(0.0) as u32).min(w);
56
- let y1 = (region.bbox[3].ceil().max(0.0) as u32).min(h);
57
- if x1 <= x0 || y1 <= y0 {
58
- continue;
59
- }
60
- for y in y0..y1 {
61
- for x in x0..x1 {
62
- mask.put_pixel(x, y, Luma([id]));
63
- }
64
- }
65
- }
66
 
67
  let blob = ctx.blobs.put_webp(&DynamicImage::ImageLuma8(mask))?;
68
  Ok(vec![upsert_mask_blob(
@@ -74,6 +35,35 @@ impl Engine for Model {
74
  }
75
  }
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  inventory::submit! {
78
  EngineInfo {
79
  id: "speech-bubble-segmentation",
@@ -86,3 +76,35 @@ inventory::submit! {
86
  }),
87
  }
88
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Speech-bubble segmentation. Produces a `Mask { Bubble }` layer where each
2
+ //! detected balloon contour is encoded as a distinct non-zero grayscale ID and
3
+ //! everything else is `0`. The renderer consumes this ID mask for safe-area
4
+ //! layout, placement, and glyph collision checks against the real bubble shape.
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  use anyhow::Result;
7
  use async_trait::async_trait;
8
  use image::{DynamicImage, GrayImage, Luma};
9
  use koharu_core::{MaskRole, Op};
10
+ use koharu_ml::speech_bubble_segmentation::{SpeechBubbleRegion, SpeechBubbleSegmentation};
11
 
12
  use crate::pipeline::artifacts::Artifact;
13
  use crate::pipeline::engine::{Engine, EngineCtx, EngineInfo};
 
21
  let image = load_source_image(ctx.scene, ctx.page, ctx.blobs)?;
22
  let result = self.0.inference(&image)?;
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  let mut regions: Vec<_> = result.regions.iter().collect();
25
  regions.sort_by_key(|region| std::cmp::Reverse(region.area));
26
+ let mask = paint_bubble_id_mask(result.image_width, result.image_height, &regions);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  let blob = ctx.blobs.put_webp(&DynamicImage::ImageLuma8(mask))?;
29
  Ok(vec![upsert_mask_blob(
 
35
  }
36
  }
37
 
38
+ fn paint_bubble_id_mask(width: u32, height: u32, regions: &[&SpeechBubbleRegion]) -> GrayImage {
39
+ let mut mask = GrayImage::from_pixel(width, height, Luma([0u8]));
40
+
41
+ // Cap at 255 IDs; typical manga pages have well under 20 bubbles.
42
+ // Larger bubbles are painted first so smaller overlapping contours keep
43
+ // their own ID when painted later.
44
+ for (i, region) in regions.iter().take(255).enumerate() {
45
+ if region.mask.is_empty() {
46
+ continue;
47
+ }
48
+ let id = (i + 1) as u8;
49
+ let src_width = region.mask.width as usize;
50
+ let max_x = region.mask.width.min(width.saturating_sub(region.mask.x));
51
+ let max_y = region.mask.height.min(height.saturating_sub(region.mask.y));
52
+ for local_y in 0..max_y {
53
+ let src_row = local_y as usize * src_width;
54
+ let y = region.mask.y + local_y;
55
+ for local_x in 0..max_x {
56
+ if region.mask.pixels[src_row + local_x as usize] == 0 {
57
+ continue;
58
+ }
59
+ mask.put_pixel(region.mask.x + local_x, y, Luma([id]));
60
+ }
61
+ }
62
+ }
63
+
64
+ mask
65
+ }
66
+
67
  inventory::submit! {
68
  EngineInfo {
69
  id: "speech-bubble-segmentation",
 
76
  }),
77
  }
78
  }
79
+
80
+ #[cfg(test)]
81
+ mod tests {
82
+ use super::*;
83
+ use koharu_ml::speech_bubble_segmentation::{SpeechBubbleRegion, SpeechBubbleRegionMask};
84
+
85
+ #[test]
86
+ fn id_mask_paints_region_contours_not_bboxes() {
87
+ let region = SpeechBubbleRegion {
88
+ label_id: 0,
89
+ label: "bubble".to_string(),
90
+ score: 0.9,
91
+ bbox: [1.0, 1.0, 4.0, 4.0],
92
+ area: 3,
93
+ mask: SpeechBubbleRegionMask {
94
+ x: 1,
95
+ y: 1,
96
+ width: 3,
97
+ height: 3,
98
+ pixels: vec![0, 255, 0, 255, 255, 0, 0, 0, 0],
99
+ },
100
+ };
101
+
102
+ let mask = paint_bubble_id_mask(6, 6, &[&region]);
103
+
104
+ assert_eq!(mask.get_pixel(1, 1).0[0], 0);
105
+ assert_eq!(mask.get_pixel(2, 1).0[0], 1);
106
+ assert_eq!(mask.get_pixel(1, 2).0[0], 1);
107
+ assert_eq!(mask.get_pixel(2, 2).0[0], 1);
108
+ assert_eq!(mask.get_pixel(3, 2).0[0], 0);
109
+ }
110
+ }
koharu-app/src/renderer.rs CHANGED
@@ -252,7 +252,6 @@ impl Renderer {
252
  .text_align
253
  .map(core_align_to_renderer)
254
  .unwrap_or(RendererTextAlign::Center);
255
- let seed_box = resolved_box.seed_box;
256
  let layout_box = resolved_box.layout_box;
257
 
258
  let mut layout_builder = TextLayout::new(&font, None)
@@ -285,7 +284,7 @@ impl Renderer {
285
  },
286
  )?;
287
  let transform = centred_sprite_transform(
288
- seed_box,
289
  rendered.width(),
290
  rendered.height(),
291
  block.transform.rotation_deg,
@@ -300,7 +299,6 @@ impl Renderer {
300
  let candidate = fit_rendered_with_mask_collision(
301
  &layout_builder,
302
  translation,
303
- writing_mode,
304
  layout_box,
305
  style.font_size,
306
  min_font_size,
@@ -329,12 +327,6 @@ impl Renderer {
329
 
330
  let candidate = render_candidate(&layout)?;
331
 
332
- // Place the sprite centred on the *seed* (detector's original
333
- // text bbox). The seed is always positioned where the source
334
- // language placed the text — inside the bubble body, never on
335
- // the tail — so anchoring here keeps translations in the body
336
- // even when the bubble bbox extends into the tail area.
337
- //
338
  Ok(Some(RenderedBlock {
339
  node_id: block.node_id,
340
  sprite: DynamicImage::ImageRgba8(candidate.image),
@@ -370,8 +362,6 @@ impl Renderer {
370
  // ---------------------------------------------------------------------------
371
 
372
  const MASK_COLLISION_ALPHA_THRESHOLD: u8 = 8;
373
- const COLLISION_SQUEEZE_FACTOR: f32 = 0.90;
374
- const COLLISION_SQUEEZE_ATTEMPTS: usize = 3;
375
  const FIT_EPSILON: f32 = 0.5;
376
 
377
  struct RenderedTextCandidate {
@@ -379,9 +369,9 @@ struct RenderedTextCandidate {
379
  transform: Transform,
380
  }
381
 
382
- struct CollisionFitAttempt {
383
- valid: Option<RenderedTextCandidate>,
384
- fallback: Option<RenderedTextCandidate>,
385
  }
386
 
387
  fn min_font_size_for_image(image_width: u32, image_height: u32) -> f32 {
@@ -457,7 +447,6 @@ fn fit_font_size<'a>(
457
  fn fit_rendered_with_mask_collision<'a, F>(
458
  layout_builder: &TextLayout<'a>,
459
  text: &str,
460
- writing_mode: WritingMode,
461
  layout_box: LayoutBox,
462
  explicit_size: Option<f32>,
463
  min_size: f32,
@@ -469,54 +458,61 @@ fn fit_rendered_with_mask_collision<'a, F>(
469
  where
470
  F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
471
  {
472
- let upper = explicit_size.unwrap_or(max_size).max(1.0).round() as i32;
473
- let lower = min_size.max(1.0).round() as i32;
474
- let min_size = if explicit_size.is_some() {
475
- lower.min(upper)
476
- } else {
477
- lower
478
- };
479
- let max_size = upper.max(min_size);
 
 
 
 
 
 
 
480
 
481
- let min_attempt = try_mask_collision_size(
 
 
 
 
 
 
 
 
 
 
 
 
482
  layout_builder,
483
  text,
484
- writing_mode,
485
  layout_box,
486
  min_size as f32,
487
  mask,
488
  bubble_id,
489
  render_candidate,
490
  )?;
491
- let Some(mut best) = min_attempt.valid else {
492
- if let Some(fallback) = min_attempt.fallback {
493
- return Ok(fallback);
494
- }
495
- return render_collision_fallback(
496
- layout_builder,
497
- text,
498
- writing_mode,
499
- layout_box,
500
- min_size as f32,
501
- render_candidate,
502
- );
503
- };
504
 
505
  let mut lo = min_size + 1;
506
- let mut hi = max_size;
507
  while lo <= hi {
508
  let mid = lo + (hi - lo) / 2;
509
- let attempt = try_mask_collision_size(
510
  layout_builder,
511
  text,
512
- writing_mode,
513
  layout_box,
514
  mid as f32,
515
  mask,
516
  bubble_id,
517
  render_candidate,
518
- )?;
519
- if let Some(candidate) = attempt.valid {
520
  best = candidate;
521
  lo = mid + 1;
522
  } else {
@@ -531,120 +527,71 @@ where
531
  fn try_mask_collision_size<'a, F>(
532
  layout_builder: &TextLayout<'a>,
533
  text: &str,
534
- writing_mode: WritingMode,
535
  layout_box: LayoutBox,
536
  font_size: f32,
537
  mask: &GrayImage,
538
  bubble_id: u8,
539
  render_candidate: &mut F,
540
- ) -> Result<CollisionFitAttempt>
541
  where
542
  F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
543
  {
544
- let mut fallback = None;
545
- for main_extent in collision_squeeze_extents(layout_box, writing_mode) {
546
- let layout = run_collision_layout_at(
547
- layout_builder,
548
- text,
549
- writing_mode,
550
- layout_box,
551
- font_size,
552
- main_extent,
553
- )?;
554
- if !layout_fits_collision_attempt(&layout, writing_mode, layout_box, main_extent) {
555
- continue;
556
- }
557
-
558
- let candidate = render_candidate(&layout)?;
559
- if sprite_collides_with_bubble_mask(&candidate.image, &candidate.transform, mask, bubble_id)
560
- {
561
- fallback = Some(candidate);
562
- continue;
563
- }
564
- return Ok(CollisionFitAttempt {
565
- valid: Some(candidate),
566
- fallback,
567
- });
568
  }
569
 
570
- Ok(CollisionFitAttempt {
571
- valid: None,
572
- fallback,
573
- })
 
574
  }
575
 
576
- fn render_collision_fallback<'a, F>(
 
577
  layout_builder: &TextLayout<'a>,
578
  text: &str,
579
- writing_mode: WritingMode,
580
  layout_box: LayoutBox,
581
  font_size: f32,
 
 
582
  render_candidate: &mut F,
583
- ) -> Result<RenderedTextCandidate>
584
  where
585
  F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
586
  {
587
- let layout = run_collision_layout_at(
588
- layout_builder,
589
- text,
590
- writing_mode,
591
- layout_box,
592
- font_size,
593
- primary_collision_extent(layout_box, writing_mode),
594
- )?;
595
- render_candidate(&layout)
 
 
596
  }
597
 
598
  fn run_collision_layout_at<'a>(
599
  layout_builder: &TextLayout<'a>,
600
  text: &str,
601
- writing_mode: WritingMode,
602
  layout_box: LayoutBox,
603
  font_size: f32,
604
- main_extent: f32,
605
  ) -> Result<LayoutRun<'a>> {
606
- let (max_width, max_height) = match writing_mode {
607
- WritingMode::Horizontal => (main_extent, layout_box.height),
608
- WritingMode::VerticalRl => (layout_box.width, main_extent),
609
- };
610
  layout_builder
611
  .clone()
612
  .with_font_size(font_size.max(1.0))
613
- .with_max_width(max_width.max(1.0))
614
- .with_max_height(max_height.max(1.0))
615
  .run(text)
616
  }
617
 
618
- fn layout_fits_collision_attempt(
619
- layout: &LayoutRun<'_>,
620
- writing_mode: WritingMode,
621
- layout_box: LayoutBox,
622
- main_extent: f32,
623
- ) -> bool {
624
- let fits_box = layout.width <= layout_box.width + FIT_EPSILON
625
- && layout.height <= layout_box.height + FIT_EPSILON;
626
- let fits_main = match writing_mode {
627
- WritingMode::Horizontal => layout.width <= main_extent + FIT_EPSILON,
628
- WritingMode::VerticalRl => layout.height <= main_extent + FIT_EPSILON,
629
- };
630
- fits_box && fits_main
631
- }
632
-
633
- fn collision_squeeze_extents(layout_box: LayoutBox, writing_mode: WritingMode) -> Vec<f32> {
634
- let mut extents = Vec::with_capacity(COLLISION_SQUEEZE_ATTEMPTS);
635
- let mut extent = primary_collision_extent(layout_box, writing_mode);
636
- for _ in 0..COLLISION_SQUEEZE_ATTEMPTS {
637
- extents.push(extent.max(1.0));
638
- extent *= COLLISION_SQUEEZE_FACTOR;
639
- }
640
- extents
641
- }
642
-
643
- fn primary_collision_extent(layout_box: LayoutBox, writing_mode: WritingMode) -> f32 {
644
- match writing_mode {
645
- WritingMode::Horizontal => layout_box.width,
646
- WritingMode::VerticalRl => layout_box.height,
647
- }
648
  }
649
 
650
  fn sprite_collides_with_bubble_mask(
@@ -722,9 +669,9 @@ fn resolve_layout_boxes(
722
  .into_iter()
723
  .map(|(seed_box, bubble_match)| match bubble_match {
724
  // Connected bubbles can contain multiple independently detected
725
- // text blocks. Expanding all of them to the same bubble bbox makes
726
- // their layouts collide, so shared bubbles fall back to each
727
- // block's original detector box.
728
  Some(matched) if counts.get(&matched.id).copied().unwrap_or(0) == 1 => {
729
  ResolvedLayoutBox {
730
  seed_box,
@@ -934,8 +881,6 @@ fn centred_sprite_transform(
934
  sprite_height: u32,
935
  rotation_deg: f32,
936
  ) -> Transform {
937
- // Centering on the anchor_box (usually the detector's seed_box)
938
- // maintains original positioning and avoids tail drift.
939
  let sprite_w = sprite_width as f32;
940
  let sprite_h = sprite_height as f32;
941
  let cx = anchor_box.x + anchor_box.width * 0.5;
@@ -1062,7 +1007,53 @@ mod tests {
1062
  }
1063
 
1064
  #[test]
1065
- fn shared_bubble_falls_back_to_seed_boxes() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1066
  let mut mask = GrayImage::from_pixel(200, 200, Luma([0u8]));
1067
  paint_rect(&mut mask, 10, 10, 190, 190, 1);
1068
  let index = BubbleIndex::new(mask);
@@ -1139,25 +1130,6 @@ mod tests {
1139
  ));
1140
  }
1141
 
1142
- #[test]
1143
- fn collision_squeeze_extents_retry_the_primary_axis() {
1144
- let layout_box = LayoutBox {
1145
- x: 0.0,
1146
- y: 0.0,
1147
- width: 100.0,
1148
- height: 50.0,
1149
- };
1150
-
1151
- assert_eq!(
1152
- collision_squeeze_extents(layout_box, WritingMode::Horizontal),
1153
- vec![100.0, 90.0, 81.0]
1154
- );
1155
- assert_eq!(
1156
- collision_squeeze_extents(layout_box, WritingMode::VerticalRl),
1157
- vec![50.0, 45.0, 40.5]
1158
- );
1159
- }
1160
-
1161
  fn block(x: f32, y: f32, width: f32, height: f32, translation: &str) -> RenderBlockInput {
1162
  RenderBlockInput {
1163
  node_id: NodeId::new(),
@@ -1185,6 +1157,50 @@ mod tests {
1185
  }
1186
  }
1187
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1188
  #[test]
1189
  fn centred_sprite_transform_anchors_to_provided_box_center() {
1190
  let anchor = LayoutBox {
 
252
  .text_align
253
  .map(core_align_to_renderer)
254
  .unwrap_or(RendererTextAlign::Center);
 
255
  let layout_box = resolved_box.layout_box;
256
 
257
  let mut layout_builder = TextLayout::new(&font, None)
 
284
  },
285
  )?;
286
  let transform = centred_sprite_transform(
287
+ layout_box,
288
  rendered.width(),
289
  rendered.height(),
290
  block.transform.rotation_deg,
 
299
  let candidate = fit_rendered_with_mask_collision(
300
  &layout_builder,
301
  translation,
 
302
  layout_box,
303
  style.font_size,
304
  min_font_size,
 
327
 
328
  let candidate = render_candidate(&layout)?;
329
 
 
 
 
 
 
 
330
  Ok(Some(RenderedBlock {
331
  node_id: block.node_id,
332
  sprite: DynamicImage::ImageRgba8(candidate.image),
 
362
  // ---------------------------------------------------------------------------
363
 
364
  const MASK_COLLISION_ALPHA_THRESHOLD: u8 = 8;
 
 
365
  const FIT_EPSILON: f32 = 0.5;
366
 
367
  struct RenderedTextCandidate {
 
369
  transform: Transform,
370
  }
371
 
372
+ struct MaskCollisionAttempt {
373
+ candidate: RenderedTextCandidate,
374
+ valid: bool,
375
  }
376
 
377
  fn min_font_size_for_image(image_width: u32, image_height: u32) -> f32 {
 
447
  fn fit_rendered_with_mask_collision<'a, F>(
448
  layout_builder: &TextLayout<'a>,
449
  text: &str,
 
450
  layout_box: LayoutBox,
451
  explicit_size: Option<f32>,
452
  min_size: f32,
 
458
  where
459
  F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
460
  {
461
+ if let Some(size) = explicit_size {
462
+ let attempt = render_mask_collision_attempt(
463
+ layout_builder,
464
+ text,
465
+ layout_box,
466
+ size.max(1.0),
467
+ mask,
468
+ bubble_id,
469
+ render_candidate,
470
+ )?;
471
+ return Ok(attempt.candidate);
472
+ }
473
+
474
+ let min_size = min_size.max(1.0).round() as i32;
475
+ let max_size = (max_size.max(1.0).round() as i32).max(min_size);
476
 
477
+ if let Some(candidate) = try_mask_collision_size(
478
+ layout_builder,
479
+ text,
480
+ layout_box,
481
+ max_size as f32,
482
+ mask,
483
+ bubble_id,
484
+ render_candidate,
485
+ )? {
486
+ return Ok(candidate);
487
+ }
488
+
489
+ let min_attempt = render_mask_collision_attempt(
490
  layout_builder,
491
  text,
 
492
  layout_box,
493
  min_size as f32,
494
  mask,
495
  bubble_id,
496
  render_candidate,
497
  )?;
498
+ if !min_attempt.valid {
499
+ return Ok(min_attempt.candidate);
500
+ }
501
+ let mut best = min_attempt.candidate;
 
 
 
 
 
 
 
 
 
502
 
503
  let mut lo = min_size + 1;
504
+ let mut hi = max_size - 1;
505
  while lo <= hi {
506
  let mid = lo + (hi - lo) / 2;
507
+ if let Some(candidate) = try_mask_collision_size(
508
  layout_builder,
509
  text,
 
510
  layout_box,
511
  mid as f32,
512
  mask,
513
  bubble_id,
514
  render_candidate,
515
+ )? {
 
516
  best = candidate;
517
  lo = mid + 1;
518
  } else {
 
527
  fn try_mask_collision_size<'a, F>(
528
  layout_builder: &TextLayout<'a>,
529
  text: &str,
 
530
  layout_box: LayoutBox,
531
  font_size: f32,
532
  mask: &GrayImage,
533
  bubble_id: u8,
534
  render_candidate: &mut F,
535
+ ) -> Result<Option<RenderedTextCandidate>>
536
  where
537
  F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
538
  {
539
+ let layout = run_collision_layout_at(layout_builder, text, layout_box, font_size)?;
540
+ let fits_layout_box = layout_fits_collision_attempt(&layout, layout_box);
541
+ if !fits_layout_box {
542
+ return Ok(None);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
543
  }
544
 
545
+ let candidate = render_candidate(&layout)?;
546
+ if sprite_collides_with_bubble_mask(&candidate.image, &candidate.transform, mask, bubble_id) {
547
+ return Ok(None);
548
+ }
549
+ Ok(Some(candidate))
550
  }
551
 
552
+ #[allow(clippy::too_many_arguments)]
553
+ fn render_mask_collision_attempt<'a, F>(
554
  layout_builder: &TextLayout<'a>,
555
  text: &str,
 
556
  layout_box: LayoutBox,
557
  font_size: f32,
558
+ mask: &GrayImage,
559
+ bubble_id: u8,
560
  render_candidate: &mut F,
561
+ ) -> Result<MaskCollisionAttempt>
562
  where
563
  F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
564
  {
565
+ let layout = run_collision_layout_at(layout_builder, text, layout_box, font_size)?;
566
+ let fits_layout_box = layout_fits_collision_attempt(&layout, layout_box);
567
+ let candidate = render_candidate(&layout)?;
568
+ let valid = fits_layout_box
569
+ && !sprite_collides_with_bubble_mask(
570
+ &candidate.image,
571
+ &candidate.transform,
572
+ mask,
573
+ bubble_id,
574
+ );
575
+ Ok(MaskCollisionAttempt { candidate, valid })
576
  }
577
 
578
  fn run_collision_layout_at<'a>(
579
  layout_builder: &TextLayout<'a>,
580
  text: &str,
 
581
  layout_box: LayoutBox,
582
  font_size: f32,
 
583
  ) -> Result<LayoutRun<'a>> {
 
 
 
 
584
  layout_builder
585
  .clone()
586
  .with_font_size(font_size.max(1.0))
587
+ .with_max_width(layout_box.width.max(1.0))
588
+ .with_max_height(layout_box.height.max(1.0))
589
  .run(text)
590
  }
591
 
592
+ fn layout_fits_collision_attempt(layout: &LayoutRun<'_>, layout_box: LayoutBox) -> bool {
593
+ layout.width <= layout_box.width + FIT_EPSILON
594
+ && layout.height <= layout_box.height + FIT_EPSILON
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
595
  }
596
 
597
  fn sprite_collides_with_bubble_mask(
 
669
  .into_iter()
670
  .map(|(seed_box, bubble_match)| match bubble_match {
671
  // Connected bubbles can contain multiple independently detected
672
+ // text blocks. Expanding all of them to the same safe area makes
673
+ // their layouts collide, so shared bubbles keep each block's
674
+ // original detector box.
675
  Some(matched) if counts.get(&matched.id).copied().unwrap_or(0) == 1 => {
676
  ResolvedLayoutBox {
677
  seed_box,
 
881
  sprite_height: u32,
882
  rotation_deg: f32,
883
  ) -> Transform {
 
 
884
  let sprite_w = sprite_width as f32;
885
  let sprite_h = sprite_height as f32;
886
  let cx = anchor_box.x + anchor_box.width * 0.5;
 
1007
  }
1008
 
1009
  #[test]
1010
+ fn mask_collision_fit_renders_min_size_when_no_safe_size_exists() -> Result<()> {
1011
+ let font = any_system_font();
1012
+ let layout_builder = TextLayout::new(&font, None);
1013
+ let layout_box = LayoutBox {
1014
+ x: 0.0,
1015
+ y: 0.0,
1016
+ width: 24.0,
1017
+ height: 12.0,
1018
+ };
1019
+ let mask = GrayImage::from_pixel(64, 64, Luma([0u8]));
1020
+ let mut rendered_sizes = Vec::new();
1021
+ let mut render_candidate = |layout: &LayoutRun<'_>| -> Result<RenderedTextCandidate> {
1022
+ rendered_sizes.push(layout.font_size);
1023
+ let width = layout.width.ceil().max(1.0) as u32;
1024
+ let height = layout.height.ceil().max(1.0) as u32;
1025
+ Ok(RenderedTextCandidate {
1026
+ image: RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 255])),
1027
+ transform: Transform {
1028
+ x: 0.0,
1029
+ y: 0.0,
1030
+ width: width as f32,
1031
+ height: height as f32,
1032
+ rotation_deg: 0.0,
1033
+ },
1034
+ })
1035
+ };
1036
+
1037
+ let candidate = fit_rendered_with_mask_collision(
1038
+ &layout_builder,
1039
+ "overflowing text",
1040
+ layout_box,
1041
+ None,
1042
+ 12.0,
1043
+ 18.0,
1044
+ &mask,
1045
+ 1,
1046
+ &mut render_candidate,
1047
+ )?;
1048
+
1049
+ assert_eq!(rendered_sizes.last().copied(), Some(12.0));
1050
+ assert!(candidate.image.width() >= 1);
1051
+ assert!(candidate.image.height() >= 1);
1052
+ Ok(())
1053
+ }
1054
+
1055
+ #[test]
1056
+ fn shared_bubble_keeps_seed_boxes_to_avoid_overlap() {
1057
  let mut mask = GrayImage::from_pixel(200, 200, Luma([0u8]));
1058
  paint_rect(&mut mask, 10, 10, 190, 190, 1);
1059
  let index = BubbleIndex::new(mask);
 
1130
  ));
1131
  }
1132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1133
  fn block(x: f32, y: f32, width: f32, height: f32, translation: &str) -> RenderBlockInput {
1134
  RenderBlockInput {
1135
  node_id: NodeId::new(),
 
1157
  }
1158
  }
1159
 
1160
+ fn any_system_font() -> Font {
1161
+ let mut book = FontBook::new();
1162
+ let preferred = [
1163
+ "Yu Gothic",
1164
+ "MS Gothic",
1165
+ "Noto Sans CJK JP",
1166
+ "Noto Sans",
1167
+ "Arial",
1168
+ "DejaVu Sans",
1169
+ "Liberation Sans",
1170
+ ];
1171
+
1172
+ for name in preferred {
1173
+ if let Some(post_script_name) = book
1174
+ .all_families()
1175
+ .into_iter()
1176
+ .find(|face| {
1177
+ face.post_script_name == name
1178
+ || face
1179
+ .families
1180
+ .iter()
1181
+ .any(|(family, _)| family.as_str() == name)
1182
+ })
1183
+ .map(|face| face.post_script_name)
1184
+ .filter(|post_script_name| !post_script_name.is_empty())
1185
+ && let Ok(font) = book.query(&post_script_name)
1186
+ {
1187
+ return font;
1188
+ }
1189
+ }
1190
+
1191
+ if let Some(face) = book
1192
+ .all_families()
1193
+ .into_iter()
1194
+ .find(|face| !face.post_script_name.is_empty())
1195
+ {
1196
+ return book
1197
+ .query(&face.post_script_name)
1198
+ .expect("failed to load first system font");
1199
+ }
1200
+
1201
+ panic!("no system font available for tests");
1202
+ }
1203
+
1204
  #[test]
1205
  fn centred_sprite_transform_anchors_to_provided_box_center() {
1206
  let anchor = LayoutBox {
koharu-ml/src/speech_bubble_segmentation/mod.rs CHANGED
@@ -68,6 +68,31 @@ pub struct SpeechBubbleSegmentationResult {
68
  pub probability_map: ProbabilityMap,
69
  }
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  #[derive(Debug, Clone, Serialize)]
72
  #[serde(rename_all = "camelCase")]
73
  pub struct SpeechBubbleRegion {
@@ -76,6 +101,8 @@ pub struct SpeechBubbleRegion {
76
  pub score: f32,
77
  pub bbox: [f32; 4],
78
  pub area: u32,
 
 
79
  }
80
 
81
  #[derive(Debug, Clone)]
@@ -332,18 +359,22 @@ fn postprocess(
332
 
333
  let mut regions = Vec::with_capacity(raw_regions.len());
334
  for (region, mask) in raw_regions.iter().zip(mask_probabilities.iter()) {
335
- let area = merge_mask_into_probability_map(
336
  &mut probability_map,
337
  mask,
338
  region.bbox,
339
  config.mask_threshold,
340
- );
 
 
 
341
  regions.push(SpeechBubbleRegion {
342
  label_id: region.label_id,
343
  label: region.label.clone(),
344
  score: region.score,
345
  bbox: region.bbox,
346
  area,
 
347
  });
348
  }
349
 
@@ -519,16 +550,20 @@ fn mask_crop_window(
519
  (top, left, bottom, right)
520
  }
521
 
522
- fn merge_mask_into_probability_map(
523
  probability_map: &mut ProbabilityMap,
524
  mask: &[f32],
525
  bbox: [f32; 4],
526
  threshold: f32,
527
- ) -> u32 {
528
  let width = probability_map.width as usize;
529
  let height = probability_map.height as usize;
530
  if mask.len() != width * height {
531
- return 0;
 
 
 
 
532
  }
533
 
534
  let x1 = bbox[0].floor().clamp(0.0, probability_map.width as f32) as usize;
@@ -536,29 +571,46 @@ fn merge_mask_into_probability_map(
536
  let x2 = bbox[2].ceil().clamp(0.0, probability_map.width as f32) as usize;
537
  let y2 = bbox[3].ceil().clamp(0.0, probability_map.height as f32) as usize;
538
  if x2 <= x1 || y2 <= y1 {
539
- return 0;
540
  }
541
 
 
 
 
542
  let mut area = 0u32;
543
  for y in y1..y2.min(height) {
544
  let row_offset = y * width;
 
545
  for x in x1..x2.min(width) {
546
  let idx = row_offset + x;
547
  let value = mask[idx];
548
  if value >= threshold {
549
  area += 1;
 
550
  }
551
  if value > probability_map.values[idx] {
552
  probability_map.values[idx] = value;
553
  }
554
  }
555
  }
556
- area
 
 
 
 
 
 
 
 
 
557
  }
558
 
559
  #[cfg(test)]
560
  mod tests {
561
- use super::{PreparedInput, map_bbox_to_original, mask_crop_window};
 
 
 
562
  use candle_core::{DType, Device, Tensor};
563
 
564
  #[test]
@@ -587,4 +639,27 @@ mod tests {
587
  let (top, left, bottom, right) = mask_crop_window(1000, 500, 160, 160);
588
  assert_eq!((top, left, bottom, right), (40, 0, 120, 160));
589
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
  }
 
68
  pub probability_map: ProbabilityMap,
69
  }
70
 
71
+ #[derive(Debug, Clone)]
72
+ pub struct SpeechBubbleRegionMask {
73
+ pub x: u32,
74
+ pub y: u32,
75
+ pub width: u32,
76
+ pub height: u32,
77
+ pub pixels: Vec<u8>,
78
+ }
79
+
80
+ impl SpeechBubbleRegionMask {
81
+ pub fn empty(x: u32, y: u32) -> Self {
82
+ Self {
83
+ x,
84
+ y,
85
+ width: 0,
86
+ height: 0,
87
+ pixels: Vec::new(),
88
+ }
89
+ }
90
+
91
+ pub fn is_empty(&self) -> bool {
92
+ self.width == 0 || self.height == 0 || self.pixels.is_empty()
93
+ }
94
+ }
95
+
96
  #[derive(Debug, Clone, Serialize)]
97
  #[serde(rename_all = "camelCase")]
98
  pub struct SpeechBubbleRegion {
 
101
  pub score: f32,
102
  pub bbox: [f32; 4],
103
  pub area: u32,
104
+ #[serde(skip_serializing)]
105
+ pub mask: SpeechBubbleRegionMask,
106
  }
107
 
108
  #[derive(Debug, Clone)]
 
359
 
360
  let mut regions = Vec::with_capacity(raw_regions.len());
361
  for (region, mask) in raw_regions.iter().zip(mask_probabilities.iter()) {
362
+ let (area, region_mask) = extract_region_contour_mask(
363
  &mut probability_map,
364
  mask,
365
  region.bbox,
366
  config.mask_threshold,
367
+ )?;
368
+ if area == 0 {
369
+ continue;
370
+ }
371
  regions.push(SpeechBubbleRegion {
372
  label_id: region.label_id,
373
  label: region.label.clone(),
374
  score: region.score,
375
  bbox: region.bbox,
376
  area,
377
+ mask: region_mask,
378
  });
379
  }
380
 
 
550
  (top, left, bottom, right)
551
  }
552
 
553
+ fn extract_region_contour_mask(
554
  probability_map: &mut ProbabilityMap,
555
  mask: &[f32],
556
  bbox: [f32; 4],
557
  threshold: f32,
558
+ ) -> Result<(u32, SpeechBubbleRegionMask)> {
559
  let width = probability_map.width as usize;
560
  let height = probability_map.height as usize;
561
  if mask.len() != width * height {
562
+ bail!(
563
+ "speech bubble mask length {} does not match image area {}",
564
+ mask.len(),
565
+ width * height
566
+ );
567
  }
568
 
569
  let x1 = bbox[0].floor().clamp(0.0, probability_map.width as f32) as usize;
 
571
  let x2 = bbox[2].ceil().clamp(0.0, probability_map.width as f32) as usize;
572
  let y2 = bbox[3].ceil().clamp(0.0, probability_map.height as f32) as usize;
573
  if x2 <= x1 || y2 <= y1 {
574
+ return Ok((0, SpeechBubbleRegionMask::empty(x1 as u32, y1 as u32)));
575
  }
576
 
577
+ let mask_width = x2 - x1;
578
+ let mask_height = y2 - y1;
579
+ let mut pixels = vec![0u8; mask_width * mask_height];
580
  let mut area = 0u32;
581
  for y in y1..y2.min(height) {
582
  let row_offset = y * width;
583
+ let local_row_offset = (y - y1) * mask_width;
584
  for x in x1..x2.min(width) {
585
  let idx = row_offset + x;
586
  let value = mask[idx];
587
  if value >= threshold {
588
  area += 1;
589
+ pixels[local_row_offset + (x - x1)] = u8::MAX;
590
  }
591
  if value > probability_map.values[idx] {
592
  probability_map.values[idx] = value;
593
  }
594
  }
595
  }
596
+ Ok((
597
+ area,
598
+ SpeechBubbleRegionMask {
599
+ x: x1 as u32,
600
+ y: y1 as u32,
601
+ width: mask_width as u32,
602
+ height: mask_height as u32,
603
+ pixels,
604
+ },
605
+ ))
606
  }
607
 
608
  #[cfg(test)]
609
  mod tests {
610
+ use super::{
611
+ PreparedInput, extract_region_contour_mask, map_bbox_to_original, mask_crop_window,
612
+ };
613
+ use crate::probability_map::ProbabilityMap;
614
  use candle_core::{DType, Device, Tensor};
615
 
616
  #[test]
 
639
  let (top, left, bottom, right) = mask_crop_window(1000, 500, 160, 160);
640
  assert_eq!((top, left, bottom, right), (40, 0, 120, 160));
641
  }
642
+
643
+ #[test]
644
+ fn extract_region_contour_mask_keeps_thresholded_shape() -> anyhow::Result<()> {
645
+ let mut probability_map = ProbabilityMap::zeros(6, 5);
646
+ let mut mask = vec![0.0f32; 6 * 5];
647
+ mask[1 + 1 * 6] = 0.9;
648
+ mask[2 + 1 * 6] = 0.8;
649
+ mask[2 + 2 * 6] = 0.7;
650
+ mask[4 + 3 * 6] = 0.4;
651
+
652
+ let (area, region_mask) =
653
+ extract_region_contour_mask(&mut probability_map, &mask, [1.0, 1.0, 5.0, 4.0], 0.5)?;
654
+
655
+ assert_eq!(area, 3);
656
+ assert_eq!((region_mask.x, region_mask.y), (1, 1));
657
+ assert_eq!((region_mask.width, region_mask.height), (4, 3));
658
+ assert_eq!(region_mask.pixels[0], u8::MAX);
659
+ assert_eq!(region_mask.pixels[1], u8::MAX);
660
+ assert_eq!(region_mask.pixels[5], u8::MAX);
661
+ assert_eq!(region_mask.pixels[11], 0);
662
+ assert_eq!(probability_map.values[4 + 3 * 6], 0.4);
663
+ Ok(())
664
+ }
665
  }
koharu-renderer/src/layout.rs CHANGED
@@ -19,6 +19,8 @@ pub use crate::segment::{LineBreakSuffix, hyphenation_lang_from_tag};
19
  pub use crate::shape::{PositionedGlyph, ShapedRun, ShapingOptions, TextShaper};
20
 
21
  const HYPHENATION_MIN_WORD_LEN: usize = 8;
 
 
22
 
23
  /// Writing mode for text layout.
24
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -65,6 +67,34 @@ pub struct LayoutRun<'a> {
65
  pub font_size: f32,
66
  }
67
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  #[derive(Clone)]
69
  pub struct TextLayout<'a> {
70
  writing_mode: WritingMode,
@@ -236,92 +266,9 @@ impl<'a> TextLayout<'a> {
236
  .unwrap_or(f32::INFINITY);
237
  let max_extent_finite = max_extent.is_finite() && max_extent > 0.0;
238
 
239
- let segments = line_breaker.line_segments(text);
240
-
241
  let mut fonts: Vec<&Font> = Vec::with_capacity(1 + self.fallback_fonts.len());
242
  fonts.push(self.font);
243
  fonts.extend(self.fallback_fonts.iter());
244
- let mut lines: Vec<LayoutLine<'a>> = Vec::new();
245
-
246
- // Temporary storage for runs in the current line to be reordered
247
- struct LineRun<'a> {
248
- shaped: ShapedRun<'a>,
249
- level: unicode_bidi::Level,
250
- }
251
- struct ShapedBreakSuffix<'a> {
252
- runs: Vec<LineRun<'a>>,
253
- advance: f32,
254
- }
255
- let mut current_line_runs: Vec<LineRun<'a>> = Vec::new();
256
- let mut line_offset = 0usize;
257
- let mut current_advance = 0.0f32;
258
- let mut current_break_suffix: Option<ShapedBreakSuffix<'a>> = None;
259
-
260
- let finalize_current_line = |runs: &mut Vec<LineRun<'a>>,
261
- offset: &mut usize,
262
- visible_end: usize,
263
- next_offset: usize,
264
- lines: &mut Vec<LayoutLine<'a>>,
265
- break_suffix: Option<ShapedBreakSuffix<'a>>,
266
- force_push: bool| {
267
- if runs.is_empty() && !force_push {
268
- *offset = next_offset;
269
- return;
270
- }
271
-
272
- if let Some(mut suffix) = break_suffix {
273
- runs.append(&mut suffix.runs);
274
- }
275
-
276
- let levels: Vec<unicode_bidi::Level> = runs.iter().map(|r| r.level).collect();
277
- let visual_indices = reorder_visual(&levels);
278
-
279
- let mut line = LayoutLine {
280
- range: *offset..visible_end,
281
- direction: if self.writing_mode.is_vertical() {
282
- harfrust::Direction::TopToBottom
283
- } else {
284
- bidi_info
285
- .paragraphs
286
- .iter()
287
- .find(|p| *offset >= p.range.start && *offset <= p.range.end)
288
- .map(|p| {
289
- if p.level.is_rtl() {
290
- harfrust::Direction::RightToLeft
291
- } else {
292
- harfrust::Direction::LeftToRight
293
- }
294
- })
295
- .unwrap_or(harfrust::Direction::LeftToRight)
296
- },
297
- ..Default::default()
298
- };
299
-
300
- let mut pen_x = 0.0f32;
301
- let mut pen_y = 0.0f32;
302
-
303
- for idx in visual_indices {
304
- let run = &mut runs[idx];
305
- for glyph in std::mem::take(&mut run.shaped.glyphs) {
306
- line.glyphs.push(glyph);
307
- }
308
- if self.writing_mode.is_vertical() {
309
- pen_y -= run.shaped.y_advance;
310
- } else {
311
- pen_x += run.shaped.x_advance;
312
- }
313
- }
314
-
315
- line.advance = if self.writing_mode.is_vertical() {
316
- pen_y.abs()
317
- } else {
318
- pen_x
319
- };
320
-
321
- lines.push(line);
322
- runs.clear();
323
- *offset = next_offset;
324
- };
325
 
326
  let shape_break_suffix = |suffix: LineBreakSuffix,
327
  level: unicode_bidi::Level,
@@ -340,14 +287,15 @@ impl<'a> TextLayout<'a> {
340
  for glyph in &mut shaped.glyphs {
341
  glyph.cluster += cluster as u32;
342
  }
343
- advance += shaped.x_advance;
344
  runs.push(LineRun { shaped, level });
345
  }
346
 
347
  Ok(ShapedBreakSuffix { runs, advance })
348
  };
349
 
350
- for segment in segments {
 
351
  let segment_text = &text[segment.range.clone()];
352
 
353
  let mut segment_runs = Vec::new();
@@ -397,9 +345,9 @@ impl<'a> TextLayout<'a> {
397
  }
398
 
399
  segment_advance += if self.writing_mode.is_vertical() {
400
- shaped.y_advance
401
  } else {
402
- shaped.x_advance
403
  };
404
 
405
  segment_runs.push(LineRun { shaped, level });
@@ -414,62 +362,45 @@ impl<'a> TextLayout<'a> {
414
  None
415
  };
416
 
417
- let would_overflow = if self.writing_mode.is_vertical() {
418
- current_advance.abs() + segment_advance.abs() > max_extent
419
- } else {
420
- let break_suffix_advance = current_break_suffix
421
- .as_ref()
422
- .map_or(0.0, |suffix| suffix.advance);
423
- current_advance + break_suffix_advance + segment_advance > max_extent
424
- };
425
-
426
- if would_overflow && !current_line_runs.is_empty() {
427
- finalize_current_line(
428
- &mut current_line_runs,
429
- &mut line_offset,
430
- segment.range.start,
431
- segment.range.start,
432
- &mut lines,
433
- current_break_suffix.take(),
434
- false,
435
- );
436
- current_advance = 0.0;
437
- }
438
 
439
- current_line_runs.extend(segment_runs);
440
- current_advance += segment_advance;
441
- current_break_suffix = segment_break_suffix;
442
-
443
- if segment.is_mandatory {
444
- // Consecutive mandatory breaks (e.g. "A\n\nB") will drop the empty line:
445
- // when segment.is_mandatory and current_line_runs is empty, finalize_current_line
446
- // early-returns without pushing a LayoutLine. Consider special-casing mandatory
447
- // breaks to push an empty LayoutLine (advance=0, glyphs empty, range=offset..visible_end)
448
- // so blank lines are preserved.
449
- finalize_current_line(
450
- &mut current_line_runs,
451
- &mut line_offset,
452
- segment.range.end,
453
- segment.next_offset,
454
- &mut lines,
455
- None,
456
- true,
457
- );
458
- current_advance = 0.0;
459
- current_break_suffix = None;
460
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
  }
462
-
463
- // Finalize last line
464
- finalize_current_line(
465
- &mut current_line_runs,
466
- &mut line_offset,
467
- text.len(),
468
- text.len(),
469
- &mut lines,
470
- None,
471
- false, // Don't force push a final empty line if the text didn't end with a break
472
- );
473
 
474
  // Baselines depend only on line index and metrics. For vertical text we compute absolute X
475
  // positions within the layout bounds (0..width) so the renderer can draw from the left.
@@ -603,6 +534,153 @@ impl<'a> TextLayout<'a> {
603
  })
604
  }
605
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
606
  fn ink_bounds(&self, font_size: f32, lines: &[LayoutLine<'a>]) -> Option<(f32, f32, f32, f32)> {
607
  let mut metrics_cache = HashMap::new();
608
 
@@ -699,6 +777,74 @@ impl<'a> TextLayout<'a> {
699
  }
700
  }
701
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
702
  fn centered_x_offset(x_min: f32, x_max: f32) -> f32 {
703
  -((x_min + x_max) * 0.5)
704
  }
@@ -894,6 +1040,19 @@ mod tests {
894
  );
895
  }
896
 
 
 
 
 
 
 
 
 
 
 
 
 
 
897
  #[test]
898
  fn layout_baselines_horizontal_follow_font_metrics() -> anyhow::Result<()> {
899
  let font = any_system_font();
 
19
  pub use crate::shape::{PositionedGlyph, ShapedRun, ShapingOptions, TextShaper};
20
 
21
  const HYPHENATION_MIN_WORD_LEN: usize = 8;
22
+ const LINE_BREAK_HYPHEN_PENALTY: f32 = 2_000.0;
23
+ const LINE_BREAK_OVERFLOW_MULTIPLIER: f32 = 10_000.0;
24
 
25
  /// Writing mode for text layout.
26
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
 
67
  pub font_size: f32,
68
  }
69
 
70
+ #[derive(Clone)]
71
+ struct LineRun<'a> {
72
+ shaped: ShapedRun<'a>,
73
+ level: unicode_bidi::Level,
74
+ }
75
+
76
+ #[derive(Clone)]
77
+ struct ShapedBreakSuffix<'a> {
78
+ runs: Vec<LineRun<'a>>,
79
+ advance: f32,
80
+ }
81
+
82
+ #[derive(Clone)]
83
+ struct ShapedSegment<'a> {
84
+ range: Range<usize>,
85
+ next_offset: usize,
86
+ is_mandatory: bool,
87
+ runs: Vec<LineRun<'a>>,
88
+ advance: f32,
89
+ break_suffix: Option<ShapedBreakSuffix<'a>>,
90
+ }
91
+
92
+ #[derive(Clone, Copy, Debug)]
93
+ struct LineBreakMeasure {
94
+ advance: f32,
95
+ break_suffix_advance: f32,
96
+ }
97
+
98
  #[derive(Clone)]
99
  pub struct TextLayout<'a> {
100
  writing_mode: WritingMode,
 
266
  .unwrap_or(f32::INFINITY);
267
  let max_extent_finite = max_extent.is_finite() && max_extent > 0.0;
268
 
 
 
269
  let mut fonts: Vec<&Font> = Vec::with_capacity(1 + self.fallback_fonts.len());
270
  fonts.push(self.font);
271
  fonts.extend(self.fallback_fonts.iter());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
  let shape_break_suffix = |suffix: LineBreakSuffix,
274
  level: unicode_bidi::Level,
 
287
  for glyph in &mut shaped.glyphs {
288
  glyph.cluster += cluster as u32;
289
  }
290
+ advance += shaped.x_advance.abs();
291
  runs.push(LineRun { shaped, level });
292
  }
293
 
294
  Ok(ShapedBreakSuffix { runs, advance })
295
  };
296
 
297
+ let mut shaped_segments = Vec::new();
298
+ for segment in line_breaker.line_segments(text) {
299
  let segment_text = &text[segment.range.clone()];
300
 
301
  let mut segment_runs = Vec::new();
 
345
  }
346
 
347
  segment_advance += if self.writing_mode.is_vertical() {
348
+ shaped.y_advance.abs()
349
  } else {
350
+ shaped.x_advance.abs()
351
  };
352
 
353
  segment_runs.push(LineRun { shaped, level });
 
362
  None
363
  };
364
 
365
+ shaped_segments.push(ShapedSegment {
366
+ range: segment.range,
367
+ next_offset: segment.next_offset,
368
+ is_mandatory: segment.is_mandatory,
369
+ runs: segment_runs,
370
+ advance: segment_advance,
371
+ break_suffix: segment_break_suffix,
372
+ });
373
+ }
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
+ let mut lines: Vec<LayoutLine<'a>> = Vec::new();
376
+ let mut line_offset = 0usize;
377
+ let mut paragraph_start = 0usize;
378
+ for (index, segment) in shaped_segments.iter().enumerate() {
379
+ if !segment.is_mandatory {
380
+ continue;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  }
382
+ self.append_balanced_segment_lines(
383
+ &shaped_segments[paragraph_start..=index],
384
+ &mut line_offset,
385
+ segment.next_offset,
386
+ true,
387
+ max_extent,
388
+ &bidi_info,
389
+ &mut lines,
390
+ );
391
+ paragraph_start = index + 1;
392
+ }
393
+ if paragraph_start < shaped_segments.len() {
394
+ self.append_balanced_segment_lines(
395
+ &shaped_segments[paragraph_start..],
396
+ &mut line_offset,
397
+ text.len(),
398
+ false,
399
+ max_extent,
400
+ &bidi_info,
401
+ &mut lines,
402
+ );
403
  }
 
 
 
 
 
 
 
 
 
 
 
404
 
405
  // Baselines depend only on line index and metrics. For vertical text we compute absolute X
406
  // positions within the layout bounds (0..width) so the renderer can draw from the left.
 
534
  })
535
  }
536
 
537
+ #[allow(clippy::too_many_arguments)]
538
+ fn append_balanced_segment_lines(
539
+ &self,
540
+ segments: &[ShapedSegment<'a>],
541
+ line_offset: &mut usize,
542
+ final_next_offset: usize,
543
+ force_final_line: bool,
544
+ max_extent: f32,
545
+ bidi_info: &BidiInfo<'_>,
546
+ lines: &mut Vec<LayoutLine<'a>>,
547
+ ) {
548
+ if segments.is_empty() {
549
+ if force_final_line {
550
+ *line_offset = self.push_layout_line(
551
+ Vec::new(),
552
+ *line_offset,
553
+ *line_offset,
554
+ final_next_offset,
555
+ None,
556
+ true,
557
+ bidi_info,
558
+ lines,
559
+ );
560
+ }
561
+ return;
562
+ }
563
+
564
+ let break_indices = if max_extent.is_finite() && max_extent > 0.0 {
565
+ let measures = segments
566
+ .iter()
567
+ .map(|segment| LineBreakMeasure {
568
+ advance: segment.advance,
569
+ break_suffix_advance: segment
570
+ .break_suffix
571
+ .as_ref()
572
+ .map_or(0.0, |suffix| suffix.advance),
573
+ })
574
+ .collect::<Vec<_>>();
575
+ optimal_line_breaks(&measures, max_extent)
576
+ } else {
577
+ vec![segments.len()]
578
+ };
579
+
580
+ let mut start = 0usize;
581
+ for end in break_indices {
582
+ if end <= start || end > segments.len() {
583
+ continue;
584
+ }
585
+ let final_line = end == segments.len();
586
+ let visible_end = segments[end - 1].range.end;
587
+ let next_offset = if final_line {
588
+ final_next_offset
589
+ } else {
590
+ segments[end].range.start
591
+ };
592
+ let break_suffix = if final_line {
593
+ None
594
+ } else {
595
+ segments[end - 1].break_suffix.clone()
596
+ };
597
+ let runs = segments[start..end]
598
+ .iter()
599
+ .flat_map(|segment| segment.runs.iter().cloned())
600
+ .collect::<Vec<_>>();
601
+ *line_offset = self.push_layout_line(
602
+ runs,
603
+ *line_offset,
604
+ visible_end,
605
+ next_offset,
606
+ break_suffix,
607
+ force_final_line && final_line,
608
+ bidi_info,
609
+ lines,
610
+ );
611
+ start = end;
612
+ }
613
+ }
614
+
615
+ #[allow(clippy::too_many_arguments)]
616
+ fn push_layout_line(
617
+ &self,
618
+ mut runs: Vec<LineRun<'a>>,
619
+ offset: usize,
620
+ visible_end: usize,
621
+ next_offset: usize,
622
+ break_suffix: Option<ShapedBreakSuffix<'a>>,
623
+ force_push: bool,
624
+ bidi_info: &BidiInfo<'_>,
625
+ lines: &mut Vec<LayoutLine<'a>>,
626
+ ) -> usize {
627
+ if runs.is_empty() && !force_push {
628
+ return next_offset;
629
+ }
630
+
631
+ if let Some(mut suffix) = break_suffix {
632
+ runs.append(&mut suffix.runs);
633
+ }
634
+
635
+ let levels: Vec<unicode_bidi::Level> = runs.iter().map(|r| r.level).collect();
636
+ let visual_indices = reorder_visual(&levels);
637
+
638
+ let mut line = LayoutLine {
639
+ range: offset..visible_end,
640
+ direction: if self.writing_mode.is_vertical() {
641
+ harfrust::Direction::TopToBottom
642
+ } else {
643
+ bidi_info
644
+ .paragraphs
645
+ .iter()
646
+ .find(|p| offset >= p.range.start && offset <= p.range.end)
647
+ .map(|p| {
648
+ if p.level.is_rtl() {
649
+ harfrust::Direction::RightToLeft
650
+ } else {
651
+ harfrust::Direction::LeftToRight
652
+ }
653
+ })
654
+ .unwrap_or(harfrust::Direction::LeftToRight)
655
+ },
656
+ ..Default::default()
657
+ };
658
+
659
+ let mut pen_x = 0.0f32;
660
+ let mut pen_y = 0.0f32;
661
+
662
+ for idx in visual_indices {
663
+ let run = &mut runs[idx];
664
+ for glyph in std::mem::take(&mut run.shaped.glyphs) {
665
+ line.glyphs.push(glyph);
666
+ }
667
+ if self.writing_mode.is_vertical() {
668
+ pen_y -= run.shaped.y_advance;
669
+ } else {
670
+ pen_x += run.shaped.x_advance;
671
+ }
672
+ }
673
+
674
+ line.advance = if self.writing_mode.is_vertical() {
675
+ pen_y.abs()
676
+ } else {
677
+ pen_x
678
+ };
679
+
680
+ lines.push(line);
681
+ next_offset
682
+ }
683
+
684
  fn ink_bounds(&self, font_size: f32, lines: &[LayoutLine<'a>]) -> Option<(f32, f32, f32, f32)> {
685
  let mut metrics_cache = HashMap::new();
686
 
 
777
  }
778
  }
779
 
780
+ fn optimal_line_breaks(segments: &[LineBreakMeasure], max_extent: f32) -> Vec<usize> {
781
+ let len = segments.len();
782
+ if len == 0 {
783
+ return Vec::new();
784
+ }
785
+ if !max_extent.is_finite() || max_extent <= 0.0 {
786
+ return vec![len];
787
+ }
788
+
789
+ let mut dp = vec![f32::INFINITY; len + 1];
790
+ let mut prev = vec![None; len + 1];
791
+ dp[0] = 0.0;
792
+
793
+ for start in 0..len {
794
+ if !dp[start].is_finite() {
795
+ continue;
796
+ }
797
+ let mut advance = 0.0f32;
798
+ for end in start + 1..=len {
799
+ advance += segments[end - 1].advance;
800
+ let suffix_advance = if end < len {
801
+ segments[end - 1].break_suffix_advance
802
+ } else {
803
+ 0.0
804
+ };
805
+ let line_advance = advance + suffix_advance;
806
+ let is_single_segment = end == start + 1;
807
+ if line_advance > max_extent && !is_single_segment {
808
+ break;
809
+ }
810
+
811
+ let mut cost = dp[start] + line_break_badness(line_advance, max_extent);
812
+ if end < len && suffix_advance > 0.0 {
813
+ cost += LINE_BREAK_HYPHEN_PENALTY;
814
+ }
815
+
816
+ if cost < dp[end] {
817
+ dp[end] = cost;
818
+ prev[end] = Some(start);
819
+ }
820
+ }
821
+ }
822
+
823
+ if !dp[len].is_finite() {
824
+ return vec![len];
825
+ }
826
+
827
+ let mut breaks = Vec::new();
828
+ let mut index = len;
829
+ while index > 0 {
830
+ breaks.push(index);
831
+ let Some(previous) = prev[index] else {
832
+ return vec![len];
833
+ };
834
+ index = previous;
835
+ }
836
+ breaks.reverse();
837
+ breaks
838
+ }
839
+
840
+ fn line_break_badness(line_advance: f32, max_extent: f32) -> f32 {
841
+ if line_advance <= max_extent {
842
+ (max_extent - line_advance).powi(3)
843
+ } else {
844
+ (line_advance - max_extent).powi(3) * LINE_BREAK_OVERFLOW_MULTIPLIER
845
+ }
846
+ }
847
+
848
  fn centered_x_offset(x_min: f32, x_max: f32) -> f32 {
849
  -((x_min + x_max) * 0.5)
850
  }
 
1040
  );
1041
  }
1042
 
1043
+ #[test]
1044
+ fn optimal_line_breaks_balance_ragged_lines() {
1045
+ let segments = vec![
1046
+ LineBreakMeasure {
1047
+ advance: 30.0,
1048
+ break_suffix_advance: 0.0,
1049
+ };
1050
+ 7
1051
+ ];
1052
+
1053
+ assert_eq!(optimal_line_breaks(&segments, 100.0), vec![2, 4, 7]);
1054
+ }
1055
+
1056
  #[test]
1057
  fn layout_baselines_horizontal_follow_font_metrics() -> anyhow::Result<()> {
1058
  let font = any_system_font();