diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index ba71ddca..4b46907b 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -7,12 +7,20 @@ use vectorcraft_doc::{ AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject, }; use vectorcraft_doc::{CharStyle, Justify}; -use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, Vec2}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect}; use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape}; type Props = Vec<(&'static str, String)>; +/// One laid-out line of area type: baseline anchor (text space) and its pieces of each run. +struct AreaLine<'t> { + x: f64, + y: f64, + pieces: Vec<(usize, &'t str)>, + hyphenated: bool, +} + pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { // Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result. let baked = vectorcraft_effects::bake_document(doc); @@ -853,11 +861,8 @@ impl Writer<'_> { let lead = first.effective_leading(); let id = self.id_attr(n); let (m, on_path) = match &t.kind { - TextKind::Point => (self.xf * t.xf, None), - TextKind::Area { frame } => { - let b = frame.bounds().unwrap_or_default(); - (self.xf * t.xf * Affine::translate(Vec2::new(b.x0 + t.para.left_indent, b.y0 + first.size)), None) - } + // Area type lines are positioned in text space (see `area_lines`). + TextKind::Point | TextKind::Area { .. } => (self.xf * t.xf, None), TextKind::OnPath { path, start } => { let pid = self.fresh_id("text-path"); let d = self.path_d(path, self.xf * t.xf); @@ -874,28 +879,48 @@ impl Writer<'_> { props.extend(Self::node_props(n)); let a = self.attrs(&props); let tr = if m == Affine::IDENTITY { String::new() } else { format!(" transform=\"{}\"", self.matrix(m)) }; - let mut s = format!(""); + // Area type: laid-out lines (the `` carries the first line's position too, so readers + // that place text by its own x/y start where the first line does). + let lines = matches!(t.kind, TextKind::Area { .. }).then(|| self.area_lines(t)); + let at = match lines.as_ref().and_then(|l| l.first()) { + Some(l) => format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)), + None => String::new(), + }; + let mut s = format!(""); if let Some((pid, start)) = &on_path { s.push_str(&format!("", fmt_num(start * 100.0, 3))); } let base = self.char_props(&first); - let mut line = 0usize; - let mut pending = false; - for run in &t.runs { - let rp = self.char_props(&run.style); - let diff: Props = rp.into_iter().filter(|kv| !base.contains(kv)).collect(); - for (i, piece) in run.text.split('\n').enumerate() { - if i > 0 { - line += 1; - pending = true; + if let Some(lines) = lines { + for l in lines { + let last = l.pieces.len() - 1; + for (k, (ri, text)) in l.pieces.into_iter().enumerate() { + let pos = if k == 0 { format!(" x=\"{}\" y=\"{}\"", self.num(l.x), self.num(l.y)) } else { String::new() }; + let diff: Props = self.char_props(&t.runs[ri].style).into_iter().filter(|kv| !base.contains(kv)).collect(); + let a = self.attrs(&diff); + let hy = if k == last && l.hyphenated { "-" } else { "" }; + s.push_str(&format!("{}{hy}", xml_escape(text))); } - if piece.is_empty() { - continue; + } + } else { + let mut line = 0usize; + let mut pending = false; + for run in &t.runs { + let rp = self.char_props(&run.style); + let diff: Props = rp.into_iter().filter(|kv| !base.contains(kv)).collect(); + for (i, piece) in run.text.split('\n').enumerate() { + if i > 0 { + line += 1; + pending = true; + } + if piece.is_empty() { + continue; + } + let pos = if pending && on_path.is_none() { format!(" x=\"0\" y=\"{}\"", self.num(line as f64 * lead)) } else { String::new() }; + pending = false; + let a = self.attrs(&diff); + s.push_str(&format!("{}", xml_escape(piece))); } - let pos = if pending && on_path.is_none() { format!(" x=\"0\" y=\"{}\"", self.num(line as f64 * lead)) } else { String::new() }; - pending = false; - let a = self.attrs(&diff); - s.push_str(&format!("{}", xml_escape(piece))); } } if on_path.is_some() { @@ -904,4 +929,48 @@ impl Writer<'_> { s.push_str(""); self.line(&s); } + + /// Area type as laid out. Soft line breaks are only known to the layout, so each laid-out line + /// is placed at its baseline (text space), at its left edge, centre or right edge to match the + /// `text-anchor` of the paragraph alignment. + fn area_lines<'t>(&self, t: &'t TextObject) -> Vec> { + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let mut offsets = Vec::with_capacity(t.runs.len()); + let mut off = 0; + for r in &t.runs { + offsets.push(off); + off += r.text.len(); + } + let mut out = vec![]; + for l in &lay.lines { + // Run pieces of this line (the paragraph's `\n` is outside `start..end`). + let mut pieces: Vec<(usize, &str)> = vec![]; + for (ri, (r, &o)) in t.runs.iter().zip(&offsets).enumerate() { + let (a, b) = (l.start.max(o), l.end.min(o + r.text.len())); + if a < b { + pieces.push((ri, &r.text[a - o..b - o])); + } + } + // Spaces where the line wrapped don't belong to the line. + while let Some((_, p)) = pieces.last_mut() { + *p = p.trim_end(); + if !p.is_empty() { + break; + } + pieces.pop(); + } + if pieces.is_empty() { + continue; + } + let x = match t.para.justify { + Justify::Center | Justify::JustifyCenter => (l.x0 + l.x1) / 2.0, + Justify::Right | Justify::JustifyRight => l.x1, + _ => l.x0, + }; + // A hyphenated break: the layout adds the hyphen as a glyph with no source text. + let hyphenated = l.glyph_end > l.glyph_start && lay.glyphs.get(l.glyph_end - 1).is_some_and(|g| g.len == 0); + out.push(AreaLine { x, y: l.baseline, pieces, hyphenated }); + } + out + } } diff --git a/crates/svg/tests/roundtrip.rs b/crates/svg/tests/roundtrip.rs index f3159fe0..16acf2e6 100644 --- a/crates/svg/tests/roundtrip.rs +++ b/crates/svg/tests/roundtrip.rs @@ -599,6 +599,36 @@ fn import_opacity_wrapper_folds_into_object() { assert!(close_rect(a[0].geometric_bounds().unwrap(), Rect::new(5.0, 5.0, 15.0, 15.0), 0.01)); } +#[test] +fn area_type_exports_its_wrapped_lines() { + // Area type wraps at the frame edge; the SVG has to carry those soft line breaks (SVG text never + // wraps), with centred lines anchored on the frame's centre, not its left edge. + let mut d = Document::new(400.0, 300.0); + let text = "Centred area text that wraps onto more than one line here"; + let mut t = TextObject::point(Point::new(0.0, 0.0), text, CharStyle { size: 18.0, ..CharStyle::default() }); + t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 150.0)) }; + t.xf = Affine::translate((100.0, 50.0)); + t.para.justify = vectorcraft_doc::Justify::Center; + let id = d.alloc_id(); + let d = doc_with(vec![Node::new(id, NodeKind::Text(Box::new(t)))]); + let svg = export(&d, &ExportOptions::default()); + let line = svg.lines().find(|l| l.contains(" = line.split("= 3, "one positioned tspan per line: {line}"); + assert!(ys.windows(2).all(|w| w[0] != w[1]), "{line}"); + assert!(line.contains("text-anchor=\"middle\""), "{line}"); + // Lines are anchored at x = 100 in text space = 200 in the document, the frame's centre. + assert!(line.contains("transform=\"matrix(1 0 0 1 100 50)\"") && line.contains(" = t.plain_text().lines().map(str::to_string).collect(); + assert_eq!(lines.len(), ys.len(), "{lines:?}"); + assert_eq!(lines.join(" "), text); + assert!((t.xf.translation().x - 200.0).abs() < 0.01, "{:?}", t.xf); +} + #[test] fn import_text_tspans_and_css() { let d = import_art(