From d936e538b7e7de3e47217155d7854dd05dd4c533 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 18 Jul 2026 23:19:01 +0300 Subject: [PATCH] fix(mtext): paragraph spacing and heading placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several MTEXT layout fixes, all in the run-aware layout engine: - Honour paragraph spacing (`\p…b#/a#`) and per-paragraph line spacing (`\psm#/pse#`), which the parser already produced but layout ignored. They are sticky per-field, so a `\pxb0.25;` set once applies to every later paragraph — stacked paragraphs now read apart instead of running together. - Underline / overline / strike now continue across the blanks between words: a decorated Space atom emits its own rule, matching the exact advance lff gives the space so it meets its neighbours' rules. - Anchor the first line by its own cap height rather than the entity height, so a small heading (`\H0.2x;First column`) no longer opens a full blank line above it; the column's first paragraph still opens its own `\p…b#;` space-before as the top margin, so the heading sits where the entity data places it. Co-Authored-By: Claude Opus 4.8 --- src/entities/text_support.rs | 244 ++++++++++++++++++++++++++++++++--- 1 file changed, 226 insertions(+), 18 deletions(-) diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index e4136e6b2..4774fccd0 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -337,6 +337,24 @@ pub struct MTextLine { /// fresh column here when the entity defines any; with no columns it reads /// as a plain line break, which is what `\N` degrades to anyway. pub starts_column: bool, + /// Blank space above the paragraph, in drawing units (`\p…b#;`). Widens the + /// gap before the paragraph's first line so stacked paragraphs read apart. + pub space_before: f32, + /// Blank space below the paragraph, in drawing units (`\p…a#;`). + pub space_after: f32, + /// Per-paragraph line spacing (`\psm#` multiple / `\pse#` exact), overriding + /// the entity-level factor for this paragraph's own lines. + pub line_spacing: Option, +} + +/// A paragraph's own line-spacing rule from `\psm#` / `\pse#`, overriding the +/// entity's DXF-44 factor for that paragraph. +#[derive(Clone, Copy, Debug)] +pub enum ParaLineSpacing { + /// Multiple of the default baseline gap (`sm#`). + Multiple(f32), + /// Exact baseline-to-baseline distance in drawing units (`se#`). + Exact(f32), } impl MTextLine { @@ -380,8 +398,8 @@ pub fn adapt_mtext_paragraphs( trim_blank_edges: bool, ) -> Vec { use acadrust::entities::mtext_format::{ - parse_mtext, MTextColor, MTextLineAlignment, MTextParagraphAlignment, MTextScalar, - ParagraphProperties, SpanProperties, StackingType, + parse_mtext, MTextColor, MTextLineAlignment, MTextLineSpacing, MTextParagraphAlignment, + MTextScalar, ParagraphProperties, SpanProperties, StackingType, }; let entity_height = entity_height.max(1e-6); @@ -429,8 +447,31 @@ pub fn adapt_mtext_paragraphs( let mut lines: Vec = Vec::new(); let mut carried: Option<(Option, f32, f32, f32, Vec)> = None; + // Paragraph spacing (`b`/`a`) and line spacing (`sm`/`se`) are sticky + // per-field: a `\p…;` block that names some codes leaves the rest at their + // previous value, so spacing set once (`\pxb0.25;`) applies to every later + // paragraph until re-specified. The alignment/indent carry above is + // all-or-nothing, which would drop spacing at the first `\p` that only + // touches alignment — so spacing is tracked independently here. + let mut carried_sb = 0.0_f32; + let mut carried_sa = 0.0_f32; + let mut carried_ls: Option = None; for para in &doc.paragraphs { let props = ¶.properties; + if let Some(v) = props.spacing_before { + carried_sb = v as f32; + } + if let Some(v) = props.spacing_after { + carried_sa = v as f32; + } + match props.line_spacing { + Some(MTextLineSpacing::Multiple(m)) => { + carried_ls = Some(ParaLineSpacing::Multiple(m as f32)) + } + Some(MTextLineSpacing::Exact(e)) => carried_ls = Some(ParaLineSpacing::Exact(e as f32)), + Some(MTextLineSpacing::Default) => carried_ls = None, + None => {} + } let (align, indent_first, indent_left, indent_right, tab_stops) = if has_own_props(props) || carried.is_none() { let v = ( @@ -460,6 +501,9 @@ pub fn adapt_mtext_paragraphs( indent_right, tab_stops, runs: Vec::new(), + space_before: carried_sb, + space_after: carried_sa, + line_spacing: carried_ls, }; for span in ¶.spans { let p = &span.properties; @@ -978,6 +1022,12 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { is_first_in_paragraph: bool, /// Which column this line lives in; always 0 without a column layout. column: usize, + /// Extra gap above (paragraph's first wrapped line only) / below (last + /// line only), in drawing units, from `\p…b#/a#;`. + space_before: f32, + space_after: f32, + /// Paragraph line-spacing override applied to this line's advance. + line_spacing: Option, } let cols = opts.columns; @@ -1076,6 +1126,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { base_wf, &base_font_name, ); + let n_wrapped = wrapped.len(); for (idx, atoms) in wrapped.into_iter().enumerate() { sub_lines.push(SubLine { atoms, @@ -1086,6 +1137,15 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { tab_stops: para.tab_stops.clone(), is_first_in_paragraph: idx == 0, column, + // The gap above rides the first wrapped line; the gap below the + // last, so an interior wrap keeps normal single spacing. + space_before: if idx == 0 { para.space_before } else { 0.0 }, + space_after: if idx + 1 == n_wrapped { + para.space_after + } else { + 0.0 + }, + line_spacing: para.line_spacing, }); } } @@ -1099,6 +1159,9 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { tab_stops: Vec::new(), is_first_in_paragraph: true, column: 0, + space_before: 0.0, + space_after: 0.0, + line_spacing: None, }); } @@ -1115,7 +1178,6 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { r as f32 }) .collect(); - let n_lines = col_rows.iter().copied().max().unwrap_or(0).max(1) as f32; let ls_factor = if opts.line_spacing_factor > 0.0 { opts.line_spacing_factor } else { @@ -1125,13 +1187,27 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { let line_h = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing(); let h = entity_h; // How far the first line's glyphs actually reach above the baseline, when - // that is further than the cap height. + // that is further than the line's own cap height. // - // Ordinary letters stop at the cap, so this is `h` for almost all text and - // nothing below moves. A symbol font is the exception: its glyphs are drawn - // to straddle the cap box — the diameter sign's stroke overhangs it top and - // bottom by design — and anchoring such a line by the nominal cap alone hung - // it above where the drawing puts it. + // Ordinary letters stop at the cap, so this is the first line's nominal cap + // and nothing below moves. Two exceptions push it: a symbol font whose + // glyphs straddle the cap box (the diameter sign's stroke overhangs it top + // and bottom by design), and — the reason the floor is the *line's* cap, not + // the entity height — a small heading (`\H0.2x;First column`): anchoring by + // the full entity height opened a whole blank line above it. + let first_line_cap = sub_lines + .first() + .map(|line| { + line.atoms + .iter() + .filter_map(|a| match &a.kind { + AtomKind::Word(_) => Some(a.state.height_mul * entity_h), + _ => None, + }) + .fold(0.0_f32, f32::max) + }) + .filter(|c| *c > 0.0) + .unwrap_or(h); let first_line_ascent = sub_lines .first() .map(|line| { @@ -1151,15 +1227,73 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ) .map(|b| b.ink_max[1]) }) - .fold(h, f32::max) + .fold(first_line_cap, f32::max) }) .unwrap_or(h); + // Per-line vertical advance: a varying-height MText (inline `\H`) must + // space each line by its own content height, not the entity height — + // otherwise a small line still leaves a full-height gap and the block + // grows several times too tall. Accumulated per column (columns stack + // independently). Uniform-height text reduces to the constant `line_h`. + let per_line_h: Vec = sub_lines + .iter() + .map(|s| { + let mh = s + .atoms + .iter() + .filter_map(|a| match &a.kind { + AtomKind::Word(_) => Some(a.state.height_mul * entity_h), + _ => None, + }) + .fold(0.0_f32, f32::max); + let mh = if mh > 0.0 { mh } else { entity_h }; + // A paragraph's own `\psm#`/`\pse#` overrides the entity factor for + // its lines; `Exact` fixes the baseline gap outright. + match s.line_spacing { + Some(ParaLineSpacing::Exact(e)) if e > 0.0 => e, + Some(ParaLineSpacing::Multiple(m)) if m > 0.0 => { + mh * m * (5.0 / 3.0) * base_font.line_spacing() + } + _ => mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing(), + } + }) + .collect(); + let line_y_offset: Vec = { + let mut col_y = vec![0.0_f32; cols.count.max(1)]; + let mut started = vec![false; cols.count.max(1)]; + // Space owed below the previous paragraph in each column, paid into the + // gap at the next paragraph's first line (alongside its space-before). + let mut pending_after = vec![0.0_f32; cols.count.max(1)]; + sub_lines + .iter() + .enumerate() + .map(|(i, s)| { + let c = s.column.min(col_y.len().saturating_sub(1)); + if started[c] { + col_y[c] -= per_line_h[i]; + if s.is_first_in_paragraph { + col_y[c] -= s.space_before + pending_after[c]; + } + } else { + started[c] = true; + // The column's first paragraph still opens its space-before as + // a top margin — that gap is the entity's own `\p…b#;`, not a + // heuristic, so the heading sits where the drawing places it. + col_y[c] -= s.space_before; + } + pending_after[c] = s.space_after; + col_y[c] + }) + .collect() + }; + let total_depth = -line_y_offset.iter().copied().fold(0.0_f32, f32::min); + let v_offset = match opts.v_anchor { MTextVAnchor::Top => -first_line_ascent, - MTextVAnchor::Middle => ((n_lines - 1.0) * line_h - h) * 0.5, - MTextVAnchor::Bottom => (n_lines - 1.0) * line_h, + MTextVAnchor::Middle => (total_depth - h) * 0.5, + MTextVAnchor::Bottom => total_depth, MTextVAnchor::MiddleOfTopLine => -h * 0.5, - MTextVAnchor::MiddleOfBottomLine => (n_lines - 1.0) * line_h - h * 0.5, + MTextVAnchor::MiddleOfBottomLine => total_depth - h * 0.5, MTextVAnchor::BottomOfTopLine => 0.0, }; let attach_h_anchor = opts.attach_h_anchor; @@ -1200,7 +1334,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { col_offset * sin_r + v_offset * cos_r, ) } else { - let ly = -(li * line_h) + v_offset; + let ly = line_y_offset[i] + v_offset; (ly * (-sin_r), ly * cos_r) }; @@ -1287,7 +1421,6 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { _ => 0.0, }; let color = atom.state.color.as_ref().and_then(resolve_inline_color); - let body = decorated(text, &atom.state); let lx = cursor_x; let ly = valign_dy; @@ -1297,6 +1430,8 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { ins_x + (line_base_x + world_dx) as f64, ins_y + (line_base_y + world_dy) as f64, ]; + // Glyphs from the plain word — the SDF run and the stroke + // fallback both draw clean characters. let (strokes, fill_tris) = lff::tessellate_text_run( [0.0, 0.0], run_h, @@ -1305,15 +1440,14 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { oblique, tracking, &font_name, - &body, + text, ); + let glyph_n = strokes.len(); all_strokes.push(TextStroke { strokes, origin, color, fill_tris, - // `text` is the plain word (specials already resolved, - // no decoration markers); `run_h` is raw like the strokes. run: Some(GlyphRun { text: text.clone(), font: font_name.to_string(), @@ -1325,6 +1459,33 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { bold: atom.state.bold, }), }); + // Underline / overline / strike are lines, not glyphs; a + // run-group's strokes are suppressed by the SDF path, so + // emit the decorations in their own RUN-LESS group. Reuse + // lff's exact positions by tessellating the decorated word + // and taking the strokes it appends after the glyphs. + if atom.state.underline || atom.state.overline || atom.state.strike { + let body = decorated(text, &atom.state); + let (deco, _) = lff::tessellate_text_run( + [0.0, 0.0], + run_h, + rot, + signed_wf, + oblique, + tracking, + &font_name, + &body, + ); + if deco.len() > glyph_n { + all_strokes.push(TextStroke { + strokes: deco[glyph_n..].to_vec(), + origin, + color, + fill_tris: vec![], + run: None, + }); + } + } if opts.want_glyph_boxes { // Per-character boxes, advancing exactly as // `measure_word` does so they track the glyphs. @@ -1447,6 +1608,52 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { } AtomKind::Space => { let adv = measure_space(&atom.state, entity_h, base_wf, &base_font_name); + // A space inside a decorated span carries the underline / + // overline / strike state but draws no glyph, so without this + // the rule breaks at every inter-word gap. Emit the decoration + // for the blank the same way words do — lff advances a space by + // exactly `measure_space`, so the rule meets the neighbours'. + if atom.state.underline || atom.state.overline || atom.state.strike { + let run_h = atom.state.height_mul * entity_h; + let signed_wf = + base_wf.signum() * atom.state.width_mul * base_wf.abs(); + let oblique = base_oblique + atom.state.oblique_rad; + let font_name = resolve_font(&atom.state, &base_font_name); + let tracking = atom.state.tracking; + let valign_dy = match atom.state.valign { + 1 => (line_max_h - run_h) * 0.5, + 2 => line_max_h - run_h, + _ => 0.0, + }; + let color = + atom.state.color.as_ref().and_then(resolve_inline_color); + let lx = cursor_x; + let ly = valign_dy; + let origin: [f64; 2] = [ + ins_x + (line_base_x + lx * cos_r - ly * sin_r) as f64, + ins_y + (line_base_y + lx * sin_r + ly * cos_r) as f64, + ]; + let body = decorated(" ", &atom.state); + let (deco, _) = lff::tessellate_text_run( + [0.0, 0.0], + run_h, + rot, + signed_wf, + oblique, + tracking, + &font_name, + &body, + ); + if !deco.is_empty() { + all_strokes.push(TextStroke { + strokes: deco, + origin, + color, + fill_tris: vec![], + run: None, + }); + } + } if opts.want_glyph_boxes { let run_h = atom.state.height_mul * entity_h; let (ax, ay) = to_world(line_base_x, line_base_y, cursor_x, 0.0); @@ -1656,6 +1863,7 @@ mod adapter_tests { let lines = adapt_mtext_paragraphs("a\\Pb\\Pc", 2.5, true); assert_eq!(lines.len(), 3); } + }