Merge pull request #2574 from dotCooCoo/fix/svg-justify-all-letter-gaps

SVG export: justified and Mojikumi lines keep their letter spacing in live text
This commit is contained in:
BFlat
2026-10-17 14:34:32 +07:00
committed by GitHub
3 changed files with 284 additions and 13 deletions
+105 -13
View File
@@ -1941,7 +1941,7 @@ impl Writer<'_> {
/// One line of laid-out text starting at `start`. Default: a positioned `<tspan>` per segment
/// (only the first of an anchored line). Fewer tspans: one positioned `<tspan>` per line with
/// the style changes nested in it, positioned again only after tabs and justified word spaces.
/// the style changes nested in it, positioned again only after tabs and inline graphics.
/// Baseline shifts are relative (`dy`), undone by the next segment that isn't shifted.
/// `positioned`: false when the caller already positioned the line (an outer `<tspan>`).
fn text_line(&mut self, s: &mut String, t: &TextObject, line: &[Segment], start: (f64, f64), base: &TextBase, positioned: bool) {
@@ -1966,7 +1966,8 @@ impl Writer<'_> {
attrs.push_str(&format!(" dy=\"{}\"", self.num(shift - st.baseline_shift)));
shift = st.baseline_shift;
}
// After a tab or a justified word space the next segment is placed.
// After a tab, an inline graphic or a justified word space, and before a character that a
// reader would draw elsewhere, the next segment is placed.
place = seg.brk || (!fewer && !base.anchored && positioned);
if (st.h_scale - st.v_scale).abs() > 1e-9 {
attrs.push_str(&format!(" textLength=\"{}\" lengthAdjust=\"spacingAndGlyphs\"", self.num(seg.advance)));
@@ -2164,26 +2165,49 @@ struct Line {
struct Segment {
/// Index into `TextObject::runs`.
run: usize,
/// Where the segment starts (text space; `y` is the baseline).
/// Where a reader starts drawing the segment (text space; `y` is the baseline).
x: f64,
y: f64,
text: String,
/// The laid-out width (tracking and justification included).
/// The width a reader gives the segment (tracking included). On a line that is justified or
/// set with Mojikumi (unless `fewer`), that is the width without justification and Mojikumi
/// ([`reader_advances`]), for horizontally scaled type also without its last character's
/// kerning against the character after it ([`end_segment`]); elsewhere, the laid-out width.
advance: f64,
/// The next segment needs its own position (after a tab, or a word space of a justified line).
/// The next segment needs its own position (after a tab, a word space of a justified line, or
/// the next segment starts with a character the layout draws elsewhere than a reader would).
brk: bool,
}
/// How far a reader may draw a character from where the layout draws it before the character is
/// given its own position (points).
const PLACE_TOLERANCE: f64 = 1e-6;
/// The laid-out lines of `t` as segments: a new segment at every style change, after tabs and,
/// on justified lines, after each word space (unless `fewer`). The text comes from the clusters
/// the layout placed: soft hyphens draw nothing, a line broken by hyphenation ends in `-`, all
/// caps are upper case.
/// caps are upper case. Unless `fewer`, a line that is justified or composed with Mojikumi also
/// starts a new segment at each character the layout draws elsewhere than a reader continuing
/// the segment would ([`reader_advances`]): a Justify All line's letter gaps, the gaps between
/// the characters of a justified Japanese line, and the spacing Mojikumi changes.
fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -> Vec<Line> {
let plain = t.plain_text();
let mut lines = Vec::with_capacity(lay.lines.len());
let (mut kerned, mut unkerned) = (None, None);
for line in &lay.lines {
let para = plain.as_bytes().get(..line.start).map_or(0, |b| b.iter().filter(|&&c| c == b'\n').count());
let split_words = !fewer && !matches!(t.para_at(para).justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
let style = t.para_at(para);
let split_words = !fewer && !matches!(style.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
// Each cluster's advance and drawing offset as a reader gives them, by its byte offset.
let reader = if split_words || (!fewer && !style.mojikumi.is_none()) {
Some(&*kerned.get_or_insert_with(|| reader_advances(t, lay, true)))
} else {
None
};
// Where a reader draws the next character of the last segment, and the byte offset and
// reader's advance of the last segment's last cluster.
let mut pen = 0.0;
let mut tail = None;
let mut segs: Vec<Segment> = Vec::new();
let mut cluster = None;
let source = |g: &vectorcraft_text::PositionedGlyph| if g.len == 0 { "-" } else { plain.get(g.byte..g.byte + g.len).unwrap_or("") };
@@ -2206,26 +2230,51 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
cluster = None;
continue;
}
// The cluster as a reader sets it (a hyphen the layout adds is set as laid out).
let set = reader.and_then(|r| r.get(&g.byte)).filter(|_| g.len > 0);
if g.len > 0 && cluster == Some(g.byte) {
// Another glyph of the same cluster.
if let Some(last) = segs.last_mut() {
// Another glyph of the same cluster (a reader's advance covers the whole cluster).
if let Some(last) = segs.last_mut().filter(|_| set.is_none()) {
last.advance += g.advance;
pen += g.advance;
}
continue;
}
cluster = Some(g.byte);
let (advance, x) = match set {
Some(&(advance, offset)) => {
// A glyph the layout draws before its pen (Mojikumi) starts that much earlier.
let x = g.xf.translation().x - offset;
(advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x })
}
None => (g.advance, g.origin.x),
};
let src: String = source(g).chars().filter(|c| *c != '\u{ad}').map(|c| run.style.digits.map(c)).collect();
let piece = if run.style.all_caps { src.to_uppercase() } else { src };
let brk = piece == "\t" || (split_words && !piece.is_empty() && piece.chars().all(char::is_whitespace));
let fresh = std::mem::take(&mut after_inline);
// The layout draws the character elsewhere than a reader continuing the line would.
let moved = reader.is_some()
&& segs.last().is_some_and(|last| (x - pen).abs() > PLACE_TOLERANCE || (g.origin.y - last.y).abs() > PLACE_TOLERANCE);
pen = x + advance;
match segs.last_mut() {
Some(last) if last.run == g.run && !last.brk && !fresh => {
Some(last) if last.run == g.run && !last.brk && !fresh && !moved => {
last.text.push_str(&piece);
last.advance += g.advance;
last.advance += advance;
last.brk = brk;
}
_ => segs.push(Segment { run: g.run, x: g.origin.x, y: g.origin.y, text: piece, advance: g.advance, brk }),
last => {
if let Some(last) = last {
last.brk |= moved;
end_segment(last, tail, t, lay, &mut unkerned);
}
segs.push(Segment { run: g.run, x, y: g.origin.y, text: piece, advance, brk });
}
}
tail = set.map(|_| (g.byte, advance));
}
if let Some(last) = segs.last_mut() {
end_segment(last, tail, t, lay, &mut unkerned);
}
segs.retain(|s| !s.text.is_empty());
if !segs.is_empty() {
@@ -2235,7 +2284,50 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
lines
}
/// Where a line of segments starts: its first segment's origin or, `anchor`ed (0.5: centre, 1:
/// Ends segment `seg` of horizontally scaled type, which a reader stretches to its width
/// (`textLength`): its last cluster (`tail`: byte offset and reader's advance) takes the advance
/// a reader gives it at the end of a `<tspan>`, without the font's kerning against the character
/// after it. `unkerned` holds [`reader_advances`] without kerning once a segment needs them.
fn end_segment(
seg: &mut Segment,
tail: Option<(usize, f64)>,
t: &TextObject,
lay: &vectorcraft_text::TextLayout,
unkerned: &mut Option<HashMap<usize, (f64, f64)>>,
) {
let Some((byte, advance)) = tail else { return };
if t.runs.get(seg.run).is_none_or(|r| (r.style.h_scale - r.style.v_scale).abs() <= 1e-9) {
return;
}
if let Some(&(alone, _)) = unkerned.get_or_insert_with(|| reader_advances(t, lay, false)).get(&byte) {
seg.advance += alone - advance;
}
}
/// The advance a reader of live SVG text gives each character cluster of `t` and how far from
/// its pen it draws the cluster's first glyph, keyed by the cluster's byte offset: `t` laid out
/// as point type (which isn't justified) without Mojikumi, scaled as `t`'s layout `lay` is by
/// Shrink Text to Fit. Without `kerning`, the font's pair kerning is left out (as manual
/// kerning leaves it out).
fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning: bool) -> HashMap<usize, (f64, f64)> {
let mut set = TextObject { kind: TextKind::Point, ..t.clone() };
for p in std::iter::once(&mut set.para).chain(set.paras.iter_mut()) {
p.mojikumi = vectorcraft_doc::Mojikumi::None;
}
if !kerning {
for r in &mut set.runs {
r.style.kerning.get_or_insert(0.0);
}
}
let mut out: HashMap<usize, (f64, f64)> = HashMap::new();
for g in vectorcraft_text::layout(FontDb::global(), &set).glyphs.iter().filter(|g| g.len > 0) {
let (advance, _) = out.entry(g.byte).or_insert((0.0, (g.xf.translation().x - g.origin.x) * lay.fit_scale));
*advance += g.advance * lay.fit_scale;
}
out
}
/// Where a line of segments starts: where its first segment starts or, `anchor`ed (0.5: center, 1:
/// right end), that point of its width. `None` for an empty line.
fn line_start(line: &[Segment], anchor: Option<f64>) -> Option<(f64, f64)> {
let (first, last) = (line.first()?, line.last()?);
+178
View File
@@ -158,6 +158,184 @@ fn justified_lines_place_each_word() {
assert!(words >= 3, "{svg}");
}
/// The only type in `d`.
fn the_text(d: &Document) -> TextObject {
d.layers[0].children().unwrap().iter().find_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).unwrap()
}
/// A drawn character: its text, where its first glyph is drawn, and the glyph's horizontal scale
/// (font units to user space).
type Drawn = (String, Point, f64);
/// Each character a usvg draws from `svg` (with the bundled font and the font files of `faces`)
/// and how it draws the character's first glyph, in SVG user space and text order; spaces and
/// tabs are left out.
fn viewer_glyph_positions(svg: &str, faces: &[Arc<vectorcraft_text::FontFace>]) -> Vec<Drawn> {
let mut db = usvg::fontdb::Database::new();
db.load_font_data(std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/SourceSans3-Regular.ttf")).unwrap());
for f in faces {
db.load_font_data(f.file_data().to_vec());
}
let opt = usvg::Options { fontdb: Arc::new(db), ..Default::default() };
let tree = usvg::Tree::from_str(svg, &opt).unwrap();
fn walk(g: &usvg::Group, out: &mut Vec<Drawn>) {
for n in g.children() {
match n {
usvg::Node::Group(g) => walk(g, out),
usvg::Node::Text(t) => {
for g in t.layouted().iter().flat_map(|span| &span.positioned_glyphs) {
if g.text.trim().is_empty() {
continue;
}
let m = t.abs_transform().pre_concat(g.transform());
out.push((g.text.clone(), Point::new(m.tx as f64, m.ty as f64), m.sx as f64));
}
}
_ => {}
}
}
}
let mut out = vec![];
walk(tree.root(), &mut out);
out
}
/// Each character the canvas draws of the only type in `d` and how it draws the character's
/// first glyph, in document space and text order (spaces and tabs left out), with the faces
/// it draws them in.
fn canvas_glyph_positions(d: &Document) -> (Vec<Drawn>, Vec<Arc<vectorcraft_text::FontFace>>) {
let t = the_text(d);
let db = vectorcraft_text::FontDb::global();
let lay = vectorcraft_text::layout(db, &t);
let plain = t.plain_text();
let mut out: Vec<Drawn> = vec![];
let mut faces: Vec<Arc<vectorcraft_text::FontFace>> = vec![];
let mut last = None;
for g in &lay.glyphs {
let text = if g.len == 0 { "-" } else { &plain[g.byte..g.byte + g.len] };
if text.trim().is_empty() || (g.len > 0 && last == Some(g.byte)) {
continue;
}
last = Some(g.byte);
if let Some(f) = db.face_by_id(g.font_id).filter(|f| !faces.iter().any(|o| o.id() == f.id())) {
faces.push(f);
}
let m = t.xf * g.xf;
out.push((text.to_string(), m * Point::ZERO, m.as_coeffs()[0]));
}
(out, faces)
}
/// Asserts that a viewer draws every character of `d`'s live SVG text where the canvas draws it,
/// as wide as the canvas draws it.
fn assert_drawn_in_place(d: &Document) {
let svg = export(d, &ExportOptions::default());
let (canvas, faces) = canvas_glyph_positions(d);
let viewer = viewer_glyph_positions(&svg, &faces);
let chars = |v: &[Drawn]| v.iter().map(|(s, _, _)| s.as_str()).collect::<String>();
assert_eq!(chars(&viewer), chars(&canvas), "{svg}");
for ((s, v, vs), (_, c, cs)) in viewer.iter().zip(&canvas) {
assert!((v.x - c.x).abs() < 0.05 && (v.y - c.y).abs() < 0.05, "{s}: the viewer draws it at {v:?}, the canvas at {c:?}: {svg}");
assert!((vs / cs - 1.0).abs() < 0.002, "{s}: the viewer scales it by {vs}, the canvas by {cs}: {svg}");
}
}
/// A Justify All line without word spaces spreads its letters; live text keeps the gaps, with
/// tracking and manual kerning too.
#[test]
fn justify_all_lines_keep_their_letter_gaps() {
let tracked = CharStyle { tracking: 80.0, ..style(12.0) };
let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) };
for runs in [vec![run("abcdefgh", style(12.0))], vec![run("abc", style(12.0)), run("def", tracked), run("ghi", kerned)]] {
let d = area_doc(runs, 200.0, Justify::JustifyAll);
assert_drawn_in_place(&d);
}
}
/// Justified and Mojikumi lines whose characters a reader draws where the layout does are written
/// as before: a segment per style run, and per word on a justified line.
#[test]
fn lines_without_moved_characters_are_written_as_before() {
let text = |d: &Document| {
let svg = export(d, &ExportOptions::default());
svg[svg.find("<text").unwrap()..svg.find("</text>").unwrap()].to_string()
};
let justified = area_doc(vec![run("aa bb cc dd ee ff", style(12.0))], 60.0, Justify::JustifyLeft);
let mut composed = area_doc(vec![run("Latin words only", style(12.0))], 200.0, Justify::Center);
let id = composed.layers[0].children().unwrap()[0].id;
if let Some(NodeKind::Text(t)) = composed.node_mut(id).map(|n| &mut n.kind) {
t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf;
}
let open = |x: &str, anchor: &str| {
format!(
r##"<text transform="matrix(1 0 0 1 20 20)" x="{x}" y="12" xml:space="preserve" font-family="&apos;Source Sans 3&apos;" font-size="12" fill="#000000"{anchor}>"##
)
};
let words =
[("aa ", "0", "12"), ("bb ", "15.636", "12"), ("cc ", "32.448", "12"), ("dd", "46.68", "12"), ("ee ", "0", "26.4"), ("ff", "14.304", "26.4")];
let words: String = words.iter().map(|(w, x, y)| format!(r#"<tspan x="{x}" y="{y}">{w}</tspan>"#)).collect();
assert_eq!(text(&justified), format!("{}{words}", open("0", "")));
let centered = format!(r#"{}<tspan x="100" y="12">Latin words only</tspan>"#, open("100", r#" text-anchor="middle""#));
assert_eq!(text(&composed), centered);
}
/// Type scaled horizontally on a justified line is stretched to its own width, not to the
/// justification space after it, nor to the font's kerning against the letter after it when
/// the letters are spread (AV is a kerned pair).
#[test]
fn justified_lines_keep_scaled_type_at_its_width() {
let wide = CharStyle { h_scale: 150.0, ..style(12.0) };
for justify in [Justify::JustifyLeft, Justify::JustifyAll] {
assert_drawn_in_place(&area_doc(vec![run("aa bb cc dd ee ff gg hh ii jj kk ll", wide.clone())], 120.0, justify));
assert_drawn_in_place(&area_doc(vec![run("abcdefgh", wide.clone())], 200.0, justify));
}
assert_drawn_in_place(&area_doc(vec![run("AVAVAVAV", wide)], 200.0, Justify::JustifyAll));
}
/// On lines that aren't spread, the spacing a reader applies itself (tracking, manual kerning,
/// horizontal scale, baseline shift) puts each character where the canvas draws it.
#[test]
fn ragged_lines_keep_their_character_spacing() {
let tracked = CharStyle { tracking: 80.0, ..style(12.0) };
let kerned = CharStyle { kerning: Some(-40.0), ..style(12.0) };
let wide = CharStyle { h_scale: 150.0, ..style(12.0) };
let shifted = CharStyle { baseline_shift: 3.0, ..style(12.0) };
for justify in [Justify::Left, Justify::Center, Justify::Right, Justify::JustifyLeft] {
let mut runs =
vec![run("plain ", style(12.0)), run("tracked ", tracked.clone()), run("kerned ", kerned.clone()), run("up ", shifted.clone())];
// usvg anchors a centered or right-aligned line by its width before `textLength` stretches it.
if matches!(justify, Justify::Left | Justify::JustifyLeft) {
runs.push(run("wide ", wide.clone()));
}
runs.push(run("end", style(12.0)));
assert_drawn_in_place(&area_doc(runs, 280.0, justify));
}
}
/// Justified Japanese lines spread between their characters (#344); live text keeps the gaps.
#[test]
fn justified_japanese_lines_keep_the_gaps_between_their_characters() {
let text = "日本語の文章は文字と文字の間で両端を揃えます。この段落は三行になります。";
for justify in [Justify::JustifyLeft, Justify::JustifyAll] {
assert_drawn_in_place(&area_doc(vec![run(text, style(12.0))], 100.0, justify));
}
}
/// Japanese composition (Mojikumi) closes up consecutive punctuation and adds a quarter em between
/// Japanese and Latin letters; live text keeps that spacing, on anchored lines too.
#[test]
fn japanese_composition_keeps_its_spacing() {
let text = "「かっこ」、「かな」。漢字とLatinの間。";
for justify in [Justify::Left, Justify::Center, Justify::Right] {
let mut d = area_doc(vec![run(text, style(12.0))], 280.0, justify);
let id = d.layers[0].children().unwrap()[0].id;
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.para.mojikumi = vectorcraft_doc::Mojikumi::LineEndHalf;
}
assert_drawn_in_place(&d);
}
}
/// Glyph outlines of every text a usvg (with the bundled font) lays out from `svg`, in SVG
/// user space.
fn viewer_outlines(svg: &str) -> Vec<BezPath> {
+1
View File
@@ -612,6 +612,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).
A `.svgz` file copied in a file manager pastes as its art, as a copied `.svg` file does.
In SVG export without Fewer `<tspan>` Elements, each character of live type that justification or Mojikumi moves (the letter gaps of a Justify All line without word spaces, the gaps between the characters of a justified Japanese line, closed-up punctuation, the space between Japanese and Latin letters) starts its own positioned `<tspan>`, and type scaled horizontally on a justified line is stretched to its own width (`crates/svg/tests/text_layout.rs`).
A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304).
In a `.ai` opened from its editing data, a text object on a layer that shows and doesn't print stays on that layer, made from the file's text document, even where the page doesn't draw it (`crates/eps/src/import/tests_native.rs`).
A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482).