Fix #5111: keep ligatures whole and place line starts in SVG by value

- drawn_alike ignores kerning_before, so a space before the "f" of "fi"
  no longer splits the ligature.
- SVG export takes a line's starting space from kerningBefore and the em
  shaping sets the character in (vectorcraft_text::line_start_space),
  not from where the glyph is drawn, which can include a shaping offset;
  the reader layout for justified lines leaves the space out, so those
  lines start there too.
- Type on a path writes the space as the first character's dx; a space
  after a line break, which <textPath> can't write, is outlined.
This commit is contained in:
Sven Lafebre
2026-11-11 22:09:20 +01:00
parent 6ae7051e4e
commit 7844657096
6 changed files with 102 additions and 18 deletions
+32 -12
View File
@@ -258,6 +258,19 @@ impl Writer<'_> {
self.warn("type on a path with glyphs hanging outside its brackets is outlined to keep its look");
return self.text_outlines(n, t);
}
// The space before the first character is written as its `dx`; one before a character
// after a line break, which the `<textPath>` writes as a space, keeps its look as outlines.
let plain = t.plain_text();
let mut at = 0;
let spaced_break = t.runs.iter().any(|r| {
let start = at;
at += r.text.len();
r.style.kerning_before != 0.0 && (start..at).any(|b| b > 0 && vectorcraft_text::line_start_space(&plain, b, &r.style, false) != 0.0)
});
if spaced_break {
self.warn("type on a path with space before a character after a line break is outlined to keep its look");
return self.text_outlines(n, t);
}
// A `<textPath>` holds the characters of the text, not the bullets or numbers drawn
// before them.
if !lay.markers.is_empty() {
@@ -559,10 +572,17 @@ impl Writer<'_> {
let a = self.attrs(&props);
let offset = fmt_num(at * 100.0, 3);
let mut s = format!("<text{id} xml:space=\"preserve\"{a}><textPath xlink:href=\"#{pid}\" startOffset=\"{offset}%\">");
for run in &drawn.runs {
let plain = drawn.plain_text();
let first = drawn.runs.iter().position(|r| !r.text.is_empty());
for (i, run) in drawn.runs.iter().enumerate() {
let mut diff = run_diff(&base, self.char_props(&run.style, space));
diff.extend(direction_props(&run.style));
let a = self.attrs(&diff);
let mut a = self.attrs(&diff);
// The space before the first character (`kerningBefore`) moves it along the path.
let before = if first == Some(i) { vectorcraft_text::line_start_space(&plain, 0, &run.style, false) } else { 0.0 };
if before != 0.0 && before.is_finite() {
a.push_str(&format!(" dx=\"{}\"", fmt_num(before, self.opts.decimals)));
}
s.push_str(&format!("<tspan{a}>{}</tspan>", xml_text(&run.text.replace('\n', " "))));
}
s.push_str("</textPath></text>");
@@ -752,14 +772,11 @@ fn text_lines(t: &TextObject, lay: &vectorcraft_text::TextLayout, fewer: bool) -
(advance, if (x - g.origin.x).abs() > PLACE_TOLERANCE { x } else { g.origin.x })
}
// The space before a line's first character (`kerningBefore`) is in its advance and
// moves it along the line: a reader sets it from where it is drawn, without it.
None if !lay.vertical
&& !g.rtl
&& g.len > 0
&& run.style.kerning_before != 0.0
&& vectorcraft_text::edit::starts_line(&plain, g.byte) =>
{
let before = Some(g.xf.translation().x - g.origin.x).filter(|b| b.is_finite()).unwrap_or(0.0);
// moves it along the line: a reader sets it that far along, without it. (Shrink
// Text to Fit scales it with the sizes.)
None if !lay.vertical && !g.rtl && g.len > 0 && run.style.kerning_before != 0.0 => {
let before = vectorcraft_text::line_start_space(&plain, g.byte, &run.style, false) * lay.fit_scale;
let before = if before.is_finite() { before } else { 0.0 };
(g.advance - before, g.origin.x + before)
}
None => (g.advance, g.origin.x),
@@ -849,10 +866,13 @@ fn reader_advances(t: &TextObject, lay: &vectorcraft_text::TextLayout, kerning:
// A reader spaces words and letters as the font does.
p.justification = vectorcraft_doc::JustificationOptions::default();
}
if !kerning {
for r in &mut set.runs {
for r in &mut set.runs {
if !kerning {
r.style.kerning.get_or_insert(0.0);
}
// A reader puts no space before a line's first character (`kerningBefore`): the segment
// starts that much further along instead.
r.style.kerning_before = 0.0;
}
let relaid;
let glyphs = if set == *t {
+30
View File
@@ -131,6 +131,36 @@ fn the_space_before_a_lines_first_character_moves_its_tspan() {
let svg = export(&area_doc(vec![run("H", before.clone()), run("OH", style(12.0))], 200.0, Justify::Center), &ExportOptions::default());
let x = tspans(&svg).iter().find_map(|t| attr(t, "x")).unwrap();
assert!((x - 101.5).abs() < 0.01, "{svg}");
// A justified line, which a reader's own advances place word by word, starts there too.
let words = vec![run("H", before.clone()), run("OH OH OH OH OH OH OH OH OH OH", style(12.0))];
let svg = export(&area_doc(words, 90.0, Justify::JustifyLeft), &ExportOptions::default());
assert_eq!(attr(tspans(&svg)[0], "x"), Some(3.0), "{svg}");
}
/// On a path, the space before the first character is its `dx` along the path. One before a
/// character after a line break, which the `<textPath>` can't write, is kept as outlines.
#[test]
fn the_space_before_the_first_character_on_a_path_is_its_dx() {
let doc = |runs: Vec<TextRun>| {
let mut t = TextObject::point(Point::ZERO, "", style(12.0));
t.runs = runs;
let mut line = BezPath::new();
line.move_to((20.0, 100.0));
line.line_to((280.0, 100.0));
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
let mut d = Document::new(300.0, 200.0);
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
vectorcraft_svg::export_full(&d, &ExportOptions::default(), None)
};
let before = CharStyle { kerning_before: 250.0, ..style(12.0) };
let out = doc(vec![run("HO", before.clone()), run("H", style(12.0))]);
let tags = tspans(&out.svg);
assert!(out.svg.contains("<textPath"), "{}", out.svg);
assert_eq!((attr(tags[0], "dx"), attr(tags[1], "dx")), (Some(3.0), None), "{}", out.svg);
let out = doc(vec![run("HO\n", style(12.0)), run("H", before)]);
assert!(!out.svg.contains("<textPath") && out.warnings.iter().any(|w| w.contains("line break")), "{:?}", out.warnings);
}
/// Proportional Metrics (#966) is written as the `palt` feature, beside the character's own.
+1 -1
View File
@@ -49,7 +49,7 @@ pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
pub use kept::{KeptLayoutsFrame, LayoutStamp, keep_layout, take_kept_layout};
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
pub use layout::{decorations, drawn, fit_first_line, fit_source_lines, layout, layout_with, scaled};
pub use shape::spacing_scale;
pub use shape::{line_start_space, spacing_scale};
pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::{AreaFit, FirstBaseline, TextFlow, VerticalAlign};
+23 -4
View File
@@ -538,6 +538,23 @@ fn space_before(text: &str, byte: usize, st: &CharStyle, spacing_em: f64) -> f64
Some(st.kerning_before / 1000.0 * spacing_em).filter(|s| s.is_finite()).unwrap_or(0.0)
}
/// The space before the character at `byte` of `text` in style `st`, in points, as the layout sets
/// it ([`CharStyle::kerning_before`]): its thousandths of the em the character is set in, when it
/// starts a line by itself, else 0. For writers that place a line's first character themselves.
pub fn line_start_space(text: &str, byte: usize, st: &CharStyle, vertical: bool) -> f64 {
let small = st.small_caps.is_some() && !st.all_caps && text.get(byte..).and_then(|s| s.chars().next()).is_some_and(char::is_lowercase);
space_before(text, byte, st, glyph_size(st, small) * spacing_scale(st, vertical))
}
/// The size characters in style `st` are shaped at: Superscript, Subscript and synthesized Small
/// Caps (`small`: a lowercase letter in them) shrink the glyphs; line metrics keep the full size.
fn glyph_size(st: &CharStyle, small: bool) -> f64 {
let full = st.size.max(0.0);
let (pos_scale, _) = st.position.scale_shift(full);
let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 };
full * pos_scale * small_scale
}
/// Line segments of a glyph outline in font units, cached for the current shaping pass.
type Profile = Vec<(f64, f64, f64, f64)>;
type CachedProfile = (Profile, (f64, f64));
@@ -833,8 +850,11 @@ fn source_run_at(runs: &[(Range<usize>, &CharStyle)], span: &Range<usize>, byte:
/// Can one glyph draw characters in styles `a` and `b`? Only when the styles are equal, except that
/// horizontal type (`vertical` false) does not read Standard Vertical Roman Alignment or the
/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there.
/// tate-chu-yoko offsets, so styles that differ only in those are drawn alike there. The space
/// before a line's first character ([`space_before`]) is never read inside a cluster, which takes it
/// from its first character, so it doesn't split a ligature either.
fn drawn_alike(a: &CharStyle, b: &CharStyle, vertical: bool) -> bool {
let b = &CharStyle { kerning_before: a.kerning_before, ..b.clone() };
if a == b {
return true;
}
@@ -1099,9 +1119,8 @@ fn shape_segment(text: &str, context: &Range<usize>, seg: &Segment, feats: &OtFe
let cursive = text_seg.chars().map(|c| c.script()).find(|s| !matches!(s, Script::Common | Script::Inherited)).is_some_and(joins);
let full = st.size.max(0.0);
// Superscript/subscript and small capitals shrink the glyphs; line metrics keep the full size.
let (pos_scale, pos_shift) = st.position.scale_shift(full);
let small_scale = if small { st.small_caps.unwrap_or(100.0) / 100.0 } else { 1.0 };
let size = full * pos_scale * small_scale;
let (_, pos_shift) = st.position.scale_shift(full);
let size = glyph_size(st, small);
let k = size / face.upem;
let km = full / face.upem;
let hs = st.h_scale / 100.0;
+15
View File
@@ -119,3 +119,18 @@ fn in_vertical_type_the_first_character_moves_down_its_column() {
}
}
}
#[test]
fn a_space_before_the_first_letter_of_a_ligature_keeps_it_whole() {
// Set on the "f" alone, as the caret before it sets it: Source Serif 4's "fi" ligature stays one
// glyph, and moves.
let serif = CharStyle { font_family: "Source Serif 4".into(), ..style(100.0) };
let plain = TextObject::point(Point::ZERO, "fit", serif);
let glyphs = |t: &TextObject| layout(db(), t).glyphs.len();
assert_eq!(glyphs(&plain), 2, "the font joins fi");
let kerned = spaced(&plain, 0, 250.0);
assert_eq!(glyphs(&kerned), 2, "still joined");
let moved = moves(&plain, &kerned, &[0, 2]);
assert!(near(&moved, &[25.0, 25.0]), "{moved:?}");
assert!((line_start_space("fit", 0, &kerned.runs[0].style, false) - 25.0).abs() < 1e-9);
}
+1 -1
View File
@@ -1122,7 +1122,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Vertical type spacing shortcuts:** The [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) page gives Alt+Down/Up for kerning at a caret or tracking of selected text, Alt+Left/Right for leading, and Alt+Shift+Right/Left for baseline shift. Alt+Shift+Down/Up also steps kerning or tracking. Down increases kerning or tracking, Left increases leading, and Shift+Right increases baseline shift; the opposite arrows decrease them. Cmd/Ctrl makes five steps. Horizontal type keeps its existing mapping; mixed horizontal and vertical text-object selections take the horizontal mapping for both objects in one undo step. Selected vertical point, area and path type use the same directions. Tests: `spacing_arrows_follow_each_text_objects_orientation`, `alt_arrows_rotate_for_selected_vertical_type`, `mixed_type_objects_take_horizontal_spacing_chords_in_both_orders`, and `selected_vertical_area_and_path_type_use_vertical_spacing_chords`.
- **Vertical punctuation:** fullwidth `!` (U+FF01) and `?` (U+FF1F) use their nominal glyph upright when no vertical alternate is present, following the Tu fallback in [Unicode UAX #50 §3.1](https://www.unicode.org/reports/tr50/#vo). Code-built test fonts cover point and area type with and without `vert` alternates, retain the rotated fallback for a bracket and long vowel mark, and leave horizontal type unchanged. Tests: `crates/text/src/tests/vertical.rs`.
- Free Distort's dialog (Effect › Distort & Transform › Free Distort…) shows a box in the proportions of the first selected object's bounds with a handle on each corner. Dragging a handle moves that corner. A handle focused by a click or Tab moves 1% of the box with each arrow key, and 10% with Shift. Reset puts the four corners back on the box. The preview and OK run `effect.apply`, or `effect.setParams` when the dialog edits an applied effect, and the Appearance panel's inline editor opens the dialog with Edit…. Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 285 (PDF page 291).
- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn; vertical type moves the character down its column. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG).
- **Kerning at the start of a line:** at the Type tool's caret before the first character of the text, of a paragraph, or after a forced line break, the Kerning field, Alt+←/→ (Alt+↓/↑ in vertical type) and Reset Tracking and Kerning set the space before the character after the caret (`kerningBefore`), as the reporter of #5111 describes the reference; the character moves along its line and the rest of the line follows, as one undo step (#5111). It used to do nothing there, or kern the invisible line break. Inside a line the caret still kerns the character before it. A line that wraps in area type takes no space before its first character, and Touch Type still moves a line's first character only up and down. SVG export starts the line where the character is drawn, and on a path writes the space as the first character's `dx`; vertical type moves the character down its column. A space on the first letter of a ligature keeps the ligature whole. Tests: `tests::caret_kerning` (engine), `tests::kerning_before` (text), `the_space_before_a_lines_first_character_moves_its_tspan` (SVG).
- **Optical kerning:** Character and Properties offer Optical beside Auto for automatic pair spacing. The side-profile spacing method is VectorCraft's own: each pair moves part of the way toward the font's own stem spacing (the gap of `nn`, else `HH` or `oo`), keeps at least part of that gap where the outlines come closest, and changes by at most 0.12 em tighter or 0.06 em looser, so ordinary text keeps the font's spacing (#5336). Ideographs, kana, hangul, fullwidth and CJK punctuation forms and dashes keep the font's spacing. Optical keeps its mode across cosmetic style runs. Choosing Optical clears manual pair values, as Auto does, and leaves additive tracking intact; a manual value typed afterwards replaces that pair's correction ([Adjust kerning and tracking](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-kerning-and-tracking.html)). Native documents retain the mode and pair overrides; old documents use Auto. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/engine/src/tests/textedit.rs`.
- **Kerning across style runs:** characters that differ only in fill, stroke, underline, strikethrough, overprint, character style name, leading, baseline shift, rotation, Character Alignment, kerning, or tracking that keeps or drops ligatures alike are shaped together, so restyling one character keeps the font's kerning pairs with its neighbors. A character with manual kerning that follows one with automatic kerning keeps the font's kerning with it (unless All Caps or Small Caps sets it as two letters, as ß becomes SS), and its value replaces the font's kerning after it. A ligature does not form across a change of style, and a change of font, size or OpenType features still shapes the characters apart. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html) (public documentation). Tests: `crates/text/src/tests/typo.rs`, `crates/text/src/tests/bidi.rs`.
- **Manual kerning in right-to-left text:** a manual kerning value on a Hebrew or Arabic character changes the space between it and the next character in reading order, the one drawn to its left, under Auto and Optical kerning. The character before it keeps its place, and the caret between the pair stays beside the next character. The side the space goes on is VectorCraft's own rule. Behavior reference: [Line and character spacing](https://web.archive.org/web/20260916050333/https://helpx.adobe.com/illustrator/using/line-character-spacing.html). Test: `rtl_manual_kerning_spaces_the_character_from_the_next_in_reading_order`.