diff --git a/crates/svg/src/export/text.rs b/crates/svg/src/export/text.rs index 5ff58a97bf..1e5fec21bd 100644 --- a/crates/svg/src/export/text.rs +++ b/crates/svg/src/export/text.rs @@ -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 `` 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 `` 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!(""); - 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!("{}", xml_text(&run.text.replace('\n', " ")))); } s.push_str(""); @@ -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 { diff --git a/crates/svg/tests/it/text_layout.rs b/crates/svg/tests/it/text_layout.rs index e8129a6922..e858f1ba08 100644 --- a/crates/svg/tests/it/text_layout.rs +++ b/crates/svg/tests/it/text_layout.rs @@ -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 `` 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| { + 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(" 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, &CharStyle)], span: &Range, 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, 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; diff --git a/crates/text/src/tests/kerning_before.rs b/crates/text/src/tests/kerning_before.rs index c33f3fd97a..f3d9e785a8 100644 --- a/crates/text/src/tests/kerning_before.rs +++ b/crates/text/src/tests/kerning_before.rs @@ -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); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7b92606edb..b98b9bb77e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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`.