this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: multi-line blank highlights use per-line rects instead of one union box

blank_boxes is now Vec<Vec<[f32; 4]>> where each inner vec has one merged
rect per rendered text line. cluster_into_lines groups glyphs by y-center
proximity (60% line-height threshold) so blanks wrapping across lines get
correct per-line highlight rectangles in the editor overlay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

+81 -44
+55 -25
crates/tala-typst/src/lib.rs
··· 30 30 pub rgba: Vec<u8>, 31 31 pub width: u32, 32 32 pub height: u32, 33 - /// Pixel-space [x, y, w, h] bounding boxes for each blank, in input order. 34 - /// Populated only when `blank_spans` is non-empty. Zero-rect if a blank 33 + /// Per-blank list of pixel-space [x, y, w, h] rects, one per rendered line. 34 + /// Populated only when `blank_spans` is non-empty. Empty inner vec if a blank 35 35 /// produced no glyphs (e.g. empty content or parse mismatch). 36 - pub blank_boxes: Vec<[f32; 4]>, 36 + pub blank_boxes: Vec<Vec<[f32; 4]>>, 37 37 /// Pixel-space `[x, y, w, h]`, fragment-relative byte range, and math flag 38 38 /// for every glyph rendered from the fragment (preamble glyphs excluded). 39 39 pub glyph_map: Vec<([f32; 4], Range<usize>, bool)>, ··· 120 120 [x0, y0, x1 - x0, y1 - y0] 121 121 } 122 122 123 - /// Compute per-blank pixel bounding boxes by combining: 123 + /// Group a flat list of glyph/shape rects into one merged rect per rendered line. 124 + /// 125 + /// Two rects are considered on the same line if their y-centers are within 126 + /// 60% of the current line's height of each other. Rects are sorted by 127 + /// center-y before clustering so the order of input doesn't matter. 128 + fn cluster_into_lines(mut rects: Vec<[f32; 4]>) -> Vec<[f32; 4]> { 129 + if rects.is_empty() { 130 + return vec![]; 131 + } 132 + rects.sort_by(|a, b| { 133 + (a[1] + a[3] * 0.5) 134 + .partial_cmp(&(b[1] + b[3] * 0.5)) 135 + .unwrap() 136 + }); 137 + 138 + let mut lines: Vec<[f32; 4]> = Vec::new(); 139 + let mut current = rects[0]; 140 + let mut line_center_y = current[1] + current[3] * 0.5; 141 + 142 + for r in rects.into_iter().skip(1) { 143 + let cy = r[1] + r[3] * 0.5; 144 + let threshold = current[3] * 0.6; 145 + if (cy - line_center_y).abs() <= threshold { 146 + current = merge_rect(current, r); 147 + } else { 148 + lines.push(current); 149 + current = r; 150 + line_center_y = cy; 151 + } 152 + } 153 + lines.push(current); 154 + lines 155 + } 156 + 157 + /// Compute per-blank, per-line pixel bounding boxes by combining: 124 158 /// - Text glyph positions from `glyph_map` (per-character source ranges). 125 159 /// - Shape positions from the frame walk (shapes carry source spans covering math 126 160 /// constructs like the radical vinculum whose `π` argument has no per-glyph span). 127 161 /// 162 + /// Each blank returns a `Vec<[f32; 4]>` with one merged rect per text line. 128 163 /// `blank_spans` are fragment-relative (same coordinate space as glyph_map ranges). 129 164 fn collect_blank_boxes( 130 165 frame: &typst::layout::Frame, ··· 133 168 source: &Source, 134 169 preamble_offset: usize, 135 170 scale: f32, 136 - ) -> Vec<[f32; 4]> { 137 - let mut boxes: Vec<Option<[f32; 4]>> = vec![None; blank_spans.len()]; 171 + ) -> Vec<Vec<[f32; 4]>> { 172 + // Collect all contributing rects for each blank before clustering. 173 + let mut raw: Vec<Vec<[f32; 4]>> = vec![vec![]; blank_spans.len()]; 138 174 139 175 // Pass 1: text glyphs via glyph_map (per-char fragment-relative ranges). 140 176 for (rect, grange, _) in glyph_map { 141 177 for (i, span) in blank_spans.iter().enumerate() { 142 178 if grange.start >= span.start && grange.end <= span.end { 143 - boxes[i] = Some(boxes[i].map_or(*rect, |b| merge_rect(b, *rect))); 179 + raw[i].push(*rect); 144 180 } 145 181 } 146 182 } ··· 154 190 scale, 155 191 0.0, 156 192 0.0, 157 - &mut boxes, 193 + &mut raw, 158 194 ); 159 195 160 - boxes 161 - .into_iter() 162 - .map(|b| b.unwrap_or([0.0, 0.0, 0.0, 0.0])) 163 - .collect() 196 + raw.into_iter().map(cluster_into_lines).collect() 164 197 } 165 198 166 199 #[allow(clippy::too_many_arguments)] ··· 172 205 scale: f32, 173 206 ox: f32, 174 207 oy: f32, 175 - boxes: &mut Vec<Option<[f32; 4]>>, 208 + raw: &mut Vec<Vec<[f32; 4]>>, 176 209 ) { 177 210 for (pos, item) in frame.items() { 178 211 let ax = ox + pos.x.to_pt() as f32; ··· 187 220 scale, 188 221 ax, 189 222 ay, 190 - boxes, 223 + raw, 191 224 ); 192 225 } 193 226 typst::layout::FrameItem::Shape(shape, span) => { ··· 207 240 let py = (ay + bb.min.y.to_pt() as f32) * scale; 208 241 let pw = (bb.max.x - bb.min.x).to_pt() as f32 * scale; 209 242 let ph = (bb.max.y - bb.min.y).to_pt() as f32 * scale; 210 - let shape_rect = [px, py, pw, ph]; 211 - if shape_rect[2] > 0.0 { 212 - boxes[i] = 213 - Some(boxes[i].map_or(shape_rect, |b| merge_rect(b, shape_rect))); 243 + if pw > 0.0 { 244 + raw[i].push([px, py, pw, ph]); 214 245 } 215 246 } 216 247 } ··· 644 675 ]; 645 676 let result = render(&tmp, CLOZE, Preamble::Authoring, &spans).unwrap(); 646 677 assert_eq!(result.blank_boxes.len(), 2); 647 - // Both boxes should have non-zero width (glyphs found). 678 + // Both boxes should have at least one line rect with non-zero width. 648 679 assert!( 649 - result.blank_boxes[0][2] > 0.0, 680 + result.blank_boxes[0].iter().any(|r| r[2] > 0.0), 650 681 "inductance box has no width" 651 682 ); 652 683 assert!( 653 - result.blank_boxes[1][2] > 0.0, 684 + result.blank_boxes[1].iter().any(|r| r[2] > 0.0), 654 685 "capacitance box has no width" 655 686 ); 656 687 // Inductance should be to the left of capacitance (same line, earlier in text). 657 - assert!( 658 - result.blank_boxes[0][0] < result.blank_boxes[1][0], 659 - "inductance should be left of capacitance" 660 - ); 688 + let ind_x = result.blank_boxes[0][0][0]; 689 + let cap_x = result.blank_boxes[1][0][0]; 690 + assert!(ind_x < cap_x, "inductance should be left of capacitance"); 661 691 } 662 692 }
+26 -19
crates/tala/src/editor.rs
··· 101 101 b64: String, 102 102 img_w: u32, 103 103 img_h: u32, 104 - /// Normalized [x, y, w, h] in [0.0, 1.0] for each blank, in document order. 105 - blank_rects: Vec<[f64; 4]>, 104 + /// Per-blank list of normalized [x, y, w, h] rects in [0.0, 1.0], one per rendered line. 105 + blank_rects: Vec<Vec<[f64; 4]>>, 106 106 /// Normalized rect, fragment-relative byte range, is_in_math for every glyph. 107 107 glyph_map: Vec<([f64; 4], std::ops::Range<usize>, bool)>, 108 108 /// Fragment-relative byte ranges of every equation in the source. ··· 588 588 let blank_rects = result 589 589 .blank_boxes 590 590 .iter() 591 - .filter(|b| b[2] > 0.0) // skip zero-width (not found) 592 - .map(|b| { 593 - [ 594 - b[0] as f64 / iw, 595 - b[1] as f64 / ih, 596 - b[2] as f64 / iw, 597 - b[3] as f64 / ih, 598 - ] 591 + .map(|line_rects| { 592 + line_rects 593 + .iter() 594 + .filter(|b| b[2] > 0.0) // skip zero-width (not found) 595 + .map(|b| { 596 + [ 597 + b[0] as f64 / iw, 598 + b[1] as f64 / ih, 599 + b[2] as f64 / iw, 600 + b[3] as f64 / ih, 601 + ] 602 + }) 603 + .collect::<Vec<_>>() 599 604 }) 600 605 .collect(); 601 606 ··· 899 904 }); 900 905 901 906 // ── Draw mode state ──────────────────────────────────────────────────────── 902 - let mut blank_rects_sig = use_signal(Vec::<[f64; 4]>::new); 907 + let mut blank_rects_sig = use_signal(Vec::<Vec<[f64; 4]>>::new); 903 908 let mut glyph_map_sig = use_signal(Vec::<([f64; 4], std::ops::Range<usize>, bool)>::new); 904 909 let mut math_spans_sig = use_signal(Vec::<std::ops::Range<usize>>::new); 905 910 let mut image_boxes_sig = use_signal(Vec::<[f64; 4]>::new); ··· 1227 1232 .and_then(|r| r.as_ref().ok()) 1228 1233 .map(|d| format!("data:image/png;base64,{}", d.b64)) 1229 1234 .unwrap_or_default(); 1230 - let card_blank_rects: Vec<[f64; 4]> = if is_active { 1235 + let card_blank_rects: Vec<Vec<[f64; 4]>> = if is_active { 1231 1236 blank_rects.clone() 1232 1237 } else { 1233 1238 card_result.as_ref() ··· 1335 1340 class: "cloze-overlay", 1336 1341 view_box: "{vb}", 1337 1342 onclick: move |_| { selected_rect_sig.set(None); }, 1338 - for rect in &card_blank_rects { 1339 - rect { 1340 - x: "{rect[0] * iw}", y: "{rect[1] * ih}", 1341 - width: "{rect[2] * iw}", height: "{rect[3] * ih}", 1342 - fill: "rgba(255,220,50,0.35)", 1343 - stroke: "rgb(200,150,0)", 1344 - stroke_width: "1.5", rx: "3", 1343 + for line_rects in &card_blank_rects { 1344 + for rect in line_rects { 1345 + rect { 1346 + x: "{rect[0] * iw}", y: "{rect[1] * ih}", 1347 + width: "{rect[2] * iw}", height: "{rect[3] * ih}", 1348 + fill: "rgba(255,220,50,0.35)", 1349 + stroke: "rgb(200,150,0)", 1350 + stroke_width: "1.5", rx: "3", 1351 + } 1345 1352 } 1346 1353 } 1347 1354 if is_active {