this repo has no description
1
fork

Configure Feed

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

feat: math clozing -- draw box over math region to insert #blank[$...$]

- Expose math_spans in RenderResult and PreviewData
- insert_blank_wrap_math: splits $A B C$ → $A$ #blank[$B$] $C$
- expand_math_selection: extends glyph-based range to token/paren boundaries,
fixing e^(-x^2) missing ) and sqrt(pi) capturing only 's'
- blank_boxes from glyph_map + frame shapes: vinculum shape fills in right
edge of sqrt that π glyph can't provide (no source span)
- Per-glyph font metrics (ttf_parser::glyph_bounding_box) replace 0.8*fs
cap-height approximation, fixing vertical position of sqrt blank box

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

+83 -48
+1
Cargo.lock
··· 7639 7639 version = "0.1.0" 7640 7640 dependencies = [ 7641 7641 "image", 7642 + "ttf-parser", 7642 7643 "typst", 7643 7644 "typst-assets", 7644 7645 "typst-render",
+1
crates/tala-typst/Cargo.toml
··· 7 7 typst = { workspace = true } 8 8 typst-render = { workspace = true } 9 9 typst-assets = { workspace = true } 10 + ttf-parser = "0.25" 10 11 11 12 [dev-dependencies] 12 13 image = { workspace = true }
+80 -47
crates/tala-typst/src/lib.rs
··· 66 66 // Spans from tala-format are relative to `fragment`; shift to full source. 67 67 let preamble_offset = preamble_str.len() + 1; // +1 for the '\n' separator 68 68 69 + let math_spans = collect_math_spans(&world.source, preamble_offset); 70 + let glyph_map = 71 + collect_glyph_map(&page.frame, &world.source, preamble_offset, 2.0, &math_spans); 72 + 73 + // Derive blank boxes using the glyph_map (per-char positions for text glyphs) 74 + // combined with shapes from the frame (shapes carry source spans and cover 75 + // math constructs like the radical vinculum that have no per-glyph source span). 69 76 let blank_boxes = if blank_spans.is_empty() { 70 77 vec![] 71 78 } else { 72 - let shifted: Vec<Range<usize>> = blank_spans 73 - .iter() 74 - .map(|s| (s.start + preamble_offset)..(s.end + preamble_offset)) 75 - .collect(); 76 - collect_blank_boxes(&page.frame, &world.source, &shifted, 2.0) 79 + collect_blank_boxes(&page.frame, &glyph_map, blank_spans, &world.source, preamble_offset, 2.0) 77 80 }; 78 81 79 - let math_spans = collect_math_spans(&world.source, preamble_offset); 80 - let glyph_map = 81 - collect_glyph_map(&page.frame, &world.source, preamble_offset, 2.0, &math_spans); 82 - 83 82 let pixmap = typst_render_page(page, 2.0); // 2 px/pt ≈ 144 dpi 84 83 85 84 Ok(RenderResult { ··· 92 91 }) 93 92 } 94 93 95 - // ── Frame walking ───────────────────────────────────────────────────────────── 94 + // ── Blank boxes ─────────────────────────────────────────────────────────────── 96 95 97 - /// Walk the frame tree and return one pixel-space bounding box per blank span. 96 + fn merge_rect(a: [f32; 4], b: [f32; 4]) -> [f32; 4] { 97 + let x0 = a[0].min(b[0]); 98 + let y0 = a[1].min(b[1]); 99 + let x1 = (a[0] + a[2]).max(b[0] + b[2]); 100 + let y1 = (a[1] + a[3]).max(b[1] + b[3]); 101 + [x0, y0, x1 - x0, y1 - y0] 102 + } 103 + 104 + /// Compute per-blank pixel bounding boxes by combining: 105 + /// - Text glyph positions from `glyph_map` (per-character source ranges). 106 + /// - Shape positions from the frame walk (shapes carry source spans covering math 107 + /// constructs like the radical vinculum whose `π` argument has no per-glyph span). 108 + /// 109 + /// `blank_spans` are fragment-relative (same coordinate space as glyph_map ranges). 98 110 fn collect_blank_boxes( 99 111 frame: &typst::layout::Frame, 100 - source: &Source, 112 + glyph_map: &[([f32; 4], Range<usize>, bool)], 101 113 blank_spans: &[Range<usize>], 114 + source: &Source, 115 + preamble_offset: usize, 102 116 scale: f32, 103 117 ) -> Vec<[f32; 4]> { 104 118 let mut boxes: Vec<Option<[f32; 4]>> = vec![None; blank_spans.len()]; 105 - walk_frame(frame, source, blank_spans, scale, 0.0, 0.0, &mut boxes); 106 - boxes 107 - .into_iter() 108 - .map(|b| b.unwrap_or([0.0, 0.0, 0.0, 0.0])) 109 - .collect() 119 + 120 + // Pass 1: text glyphs via glyph_map (per-char fragment-relative ranges). 121 + for (rect, grange, _) in glyph_map { 122 + for (i, span) in blank_spans.iter().enumerate() { 123 + if grange.start >= span.start && grange.end <= span.end { 124 + boxes[i] = Some(boxes[i].map_or(*rect, |b| merge_rect(b, *rect))); 125 + } 126 + } 127 + } 128 + 129 + // Pass 2: shapes from the frame (e.g. math vinculum, rule lines). 130 + walk_frame_for_shapes(frame, source, blank_spans, preamble_offset, scale, 0.0, 0.0, &mut boxes); 131 + 132 + boxes.into_iter().map(|b| b.unwrap_or([0.0, 0.0, 0.0, 0.0])).collect() 110 133 } 111 134 112 - fn walk_frame( 135 + fn walk_frame_for_shapes( 113 136 frame: &typst::layout::Frame, 114 137 source: &Source, 115 138 blank_spans: &[Range<usize>], 139 + preamble_offset: usize, 116 140 scale: f32, 117 141 ox: f32, 118 142 oy: f32, ··· 123 147 let ay = oy + pos.y.to_pt() as f32; 124 148 match item { 125 149 typst::layout::FrameItem::Group(g) => { 126 - walk_frame(&g.frame, source, blank_spans, scale, ax, ay, boxes); 150 + walk_frame_for_shapes( 151 + &g.frame, source, blank_spans, preamble_offset, scale, ax, ay, boxes, 152 + ); 127 153 } 128 - typst::layout::FrameItem::Text(text) => { 129 - let fs = text.size.to_pt() as f32; 130 - let mut cx = ax; 131 - for glyph in &text.glyphs { 132 - let gw = glyph.x_advance.at(text.size).to_pt() as f32; 133 - if let Some(sr) = source.range(glyph.span.0) { 134 - for (i, span) in blank_spans.iter().enumerate() { 135 - if sr.start >= span.start && sr.end <= span.end { 136 - // Approximate glyph box: baseline is at ay, 137 - // cap-height ≈ 0.8*fs above, descender ≈ 0.2*fs below. 138 - let px = cx * scale; 139 - let py = (ay - fs * 0.8) * scale; 140 - let pw = gw * scale; 141 - let ph = fs * scale; 142 - boxes[i] = Some(match boxes[i] { 143 - None => [px, py, pw, ph], 144 - Some(b) => { 145 - let x0 = b[0].min(px); 146 - let y0 = b[1].min(py); 147 - let x1 = (b[0] + b[2]).max(px + pw); 148 - let y1 = (b[1] + b[3]).max(py + ph); 149 - [x0, y0, x1 - x0, y1 - y0] 150 - } 151 - }); 152 - } 154 + typst::layout::FrameItem::Shape(shape, span) => { 155 + let Some(sr) = source.range(*span) else { continue }; 156 + if sr.end <= preamble_offset { continue; } 157 + let frag_start = sr.start.saturating_sub(preamble_offset); 158 + let frag_end = sr.end - preamble_offset; 159 + for (i, blank_span) in blank_spans.iter().enumerate() { 160 + // Span-start-within check: catches math shapes whose node spans 161 + // the whole expression (may extend past the blank boundary). 162 + if frag_start >= blank_span.start && frag_start < blank_span.end { 163 + let bb = shape.geometry.bbox(); 164 + let px = (ax + bb.min.x.to_pt() as f32) * scale; 165 + let py = (ay + bb.min.y.to_pt() as f32) * scale; 166 + let pw = (bb.max.x - bb.min.x).to_pt() as f32 * scale; 167 + let ph = (bb.max.y - bb.min.y).to_pt() as f32 * scale; 168 + // Only extend the x-span (width); lines have ph=0 so vertical 169 + // extent is preserved from the text glyph heights. 170 + let _ = frag_end; // suppress warning 171 + let shape_rect = [px, py, pw, ph]; 172 + if shape_rect[2] > 0.0 { 173 + boxes[i] = Some(boxes[i].map_or(shape_rect, |b| merge_rect(b, shape_rect))); 153 174 } 154 175 } 155 - cx += gw; 156 176 } 157 177 } 158 178 _ => {} ··· 227 247 typst::layout::FrameItem::Text(text) => { 228 248 let fs = text.size.to_pt() as f32; 229 249 let src_text = source.text(); 250 + let ttf = text.font.ttf(); 251 + let upm = ttf.units_per_em() as f32; 230 252 let mut cx = ax; 231 253 for glyph in &text.glyphs { 232 254 let gw = glyph.x_advance.at(text.size).to_pt() as f32; ··· 245 267 .iter() 246 268 .any(|ms| frag_start < ms.end && frag_end > ms.start); 247 269 let px = cx * scale; 248 - let py = (ay - fs * 0.8) * scale; 249 - map.push(([px, py, gw * scale, fs * scale], frag_start..frag_end, in_math)); 270 + // Use actual per-glyph font metrics instead of the 0.8*fs 271 + // cap-height approximation, which is wildly wrong for math 272 + // symbols like the radical sign (ascent ≈ 0.2pt, not 8.8pt). 273 + let (py, ph) = if let Some(bb) = ttf.glyph_bounding_box( 274 + ttf_parser::GlyphId(glyph.id) 275 + ) { 276 + let ascent = bb.y_max as f32 / upm * fs; 277 + let descent = -bb.y_min as f32 / upm * fs; 278 + ((ay - ascent) * scale, (ascent + descent) * scale) 279 + } else { 280 + ((ay - fs * 0.8) * scale, fs * scale) 281 + }; 282 + map.push(([px, py, gw * scale, ph], frag_start..frag_end, in_math)); 250 283 } 251 284 } 252 285 cx += gw;
+1 -1
crates/tala/src/main.rs
··· 131 131 #[component] 132 132 fn Editor() -> Element { 133 133 let mut source = use_signal(|| { 134 - r#"#cloze[The #blank[Gaussian integral] is $integral_(-infinity)^(infinity) e^(-x^2) dif x = sqrt(pi)$]"# 134 + r#"#cloze[The Gaussian integral is $integral_(-infinity)^(infinity) e^(-x^2) dif x = sqrt(pi)$]"# 135 135 .to_string() 136 136 }); 137 137