diff --git a/crates/text/src/fontdb/face.rs b/crates/text/src/fontdb/face.rs index 506c3b1bf..382b96da2 100644 --- a/crates/text/src/fontdb/face.rs +++ b/crates/text/src/fontdb/face.rs @@ -3,6 +3,70 @@ use super::names::{localized, name}; use super::traits::{FaceKeys, FontTraits}; use super::{Arc, GlyphId, HashMap, Location, LocationRef, MetadataProvider, Size, StringId}; +/// The most glyphs one [`outline_points`] lookup reads, composite components included. +const MAX_OUTLINE_VISITS: usize = 65_536; + +/// Unhinted TrueType contour points in glyph order, including transformed composite components. +/// A point cap, a depth bound and `visits`, the number of glyphs the whole lookup may still read, +/// keep damaged fonts from making caret lookup unbounded. Callers start `visits` at +/// [`MAX_OUTLINE_VISITS`]. +fn outline_points(font: &skrifa::FontRef<'_>, gid: GlyphId, depth: usize, visits: &mut usize) -> Option> { + use skrifa::raw::TableProvider; + use skrifa::raw::tables::glyf::{Anchor, CompositeGlyphFlags, Glyph}; + + const MAX_DEPTH: usize = 16; + const MAX_POINTS: usize = 65_536; + if depth >= MAX_DEPTH { + return None; + } + *visits = visits.checked_sub(1)?; + let glyph = font.loca(None).ok()?.get(gid, &font.glyf().ok()?)?.into_glyph()?; + match glyph { + Glyph::Simple(simple) => { + if simple.num_points() > MAX_POINTS { + return None; + } + let points: Vec<_> = simple.points().map(|p| (f64::from(p.x), f64::from(p.y))).collect(); + (points.len() == simple.num_points()).then_some(points) + } + Glyph::Composite(composite) => { + let mut points = Vec::new(); + for component in composite.components() { + let child = outline_points(font, GlyphId::new(component.glyph.to_u32()), depth + 1, visits)?; + let t = component.transform; + let xx = f64::from(t.xx.to_f32()); + let xy = f64::from(t.xy.to_f32()); + let yx = f64::from(t.yx.to_f32()); + let yy = f64::from(t.yy.to_f32()); + let transformed: Vec<_> = child.into_iter().map(|(x, y)| (xx * x + xy * y, yx * x + yy * y)).collect(); + let (dx, dy) = match component.anchor { + Anchor::Offset { x, y } => { + let (x, y) = (f64::from(x), f64::from(y)); + // Each axis of a scaled offset scales by the length of its basis vector, as + // in skrifa's outline loader. UNSCALED_COMPONENT_OFFSET turns the scaling off. + let offset_flags = CompositeGlyphFlags::SCALED_COMPONENT_OFFSET | CompositeGlyphFlags::UNSCALED_COMPONENT_OFFSET; + if component.flags & offset_flags == CompositeGlyphFlags::SCALED_COMPONENT_OFFSET { + (x * xx.hypot(xy), y * yx.hypot(yy)) + } else { + (x, y) + } + } + Anchor::Point { base, component } => { + let (bx, by) = *points.get(usize::from(base))?; + let (cx, cy) = *transformed.get(usize::from(component))?; + (bx - cx, by - cy) + } + }; + if transformed.len() > MAX_POINTS.saturating_sub(points.len()) { + return None; + } + points.extend(transformed.into_iter().map(|(x, y)| (x + dx, y + dy))); + } + Some(points) + } + } +} + /// The family used when a requested family is unknown (Illustrator's Myriad Pro analogue). pub const FALLBACK_FAMILY: &str = "Source Sans 3"; @@ -244,11 +308,11 @@ impl FontFace { /// ), one between each two /// characters the glyph stands for, each an x in horizontal text and a y (up from the baseline) /// in vertical text. A coordinate with a variation index (format 3 in a variable font) takes its - /// delta from the `GDEF` item variation store at the face's location; a device table's - /// adjustments for pixel sizes are not applied. `None` when the font lists no carets for the - /// glyph, lists a number other than `count`, gives one by a contour point, or gives one a - /// variation index whose delta can't be read. - fn ligature_caret_coordinates(&self, gid: u32, count: usize) -> Option> { + /// delta from the `GDEF` item variation store at the face's location. Format 2 uses the + /// unhinted outline point at the font's default variation location. Device-table adjustments + /// for pixel sizes are not applied. `None` when the font lists no carets for the glyph, lists + /// a number other than `count`, or gives a coordinate that cannot be resolved. + fn ligature_caret_coordinates(&self, gid: u32, count: usize, vertical: bool) -> Option> { use skrifa::raw::TableProvider; use skrifa::raw::tables::gdef::CaretValue; use skrifa::raw::tables::layout::DeviceOrVariationIndex; @@ -280,17 +344,26 @@ impl FontFace { } } } - CaretValue::Format2(_) => None, + CaretValue::Format2(v) => { + // Raw glyf points have no variation deltas applied. Do not use one at a + // non-default location, where it could put the caret in the wrong place. + if self.location.coords().iter().any(|c| c.to_f32() != 0.0) { + return None; + } + let mut visits = MAX_OUTLINE_VISITS; + let point = outline_points(&font, GlyphId::new(gid), 0, &mut visits)?.get(usize::from(v.caret_value_point_index())).copied()?; + Some(if vertical { point.1 } else { point.0 }) + } }) .collect::>>()?; (!carets.is_empty()).then_some(carets) } /// The caret positions inside ligature glyph `gid` set across, in font units from its left /// edge, left to right: the x coordinates of the font's ligature caret list. `None` when the - /// font lists no carets for the glyph, lists a number other than `count`, gives one by a contour - /// point, or gives positions that are not increasing inside the glyph's advance. + /// font lists no carets for the glyph, lists a number other than `count`, or gives positions + /// that are not increasing inside the glyph's advance. pub fn ligature_carets(&self, gid: u32, count: usize) -> Option> { - let carets = self.ligature_caret_coordinates(gid, count)?; + let carets = self.ligature_caret_coordinates(gid, count, false)?; let advance = self.advance(gid); let inside = carets.iter().all(|x| *x > 0.0 && *x < advance) && carets.windows(2).all(|w| w[0] < w[1]); inside.then_some(carets) @@ -298,10 +371,10 @@ impl FontFace { /// The caret positions inside ligature glyph `gid` standing upright in vertical type, in font /// units down from the top of its cell, top to bottom: the y coordinates of the font's ligature /// caret list, for a cell `cell` long whose top is `top` above the baseline. `None` when the - /// font lists no carets for the glyph, lists a number other than `count`, gives one by a contour - /// point, or gives positions that are not increasing inside the cell. + /// font lists no carets for the glyph, lists a number other than `count`, or gives positions + /// that are not increasing inside the cell. pub fn vertical_ligature_carets(&self, gid: u32, count: usize, top: f64, cell: f64) -> Option> { - let carets = self.ligature_caret_coordinates(gid, count)?; + let carets = self.ligature_caret_coordinates(gid, count, true)?; let inside = carets.iter().all(|y| *y < top && *y > top - cell) && carets.windows(2).all(|w| w[0] < w[1]); inside.then(|| carets.iter().rev().map(|y| top - y).collect()) } diff --git a/crates/text/src/tests/ligature_carets.rs b/crates/text/src/tests/ligature_carets.rs index a0a194d9b..929ba50bc 100644 --- a/crates/text/src/tests/ligature_carets.rs +++ b/crates/text/src/tests/ligature_carets.rs @@ -19,6 +19,175 @@ fn db_with(font: Option>) -> FontDb { db } +/// Put a contour-point caret on the existing test ligature without changing its outline. +fn contour_caret_font(point_index: u16) -> Vec { + use write_fonts::FontBuilder; + use write_fonts::tables::gdef::{CaretValue, Gdef, LigCaretList, LigGlyph}; + use write_fonts::tables::layout::CoverageFormat1 as WriteCoverage; + use write_fonts::types::{GlyphId16, Tag}; + + let base = ligature_caret_font(None).expect("test font builds"); + let font = skrifa::FontRef::new(&base).expect("test font parses"); + let gid = source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph"); + let mut builder = FontBuilder::new(); + let list = LigCaretList::new(WriteCoverage::new(vec![GlyphId16::new(gid)]).into(), vec![LigGlyph::new(vec![CaretValue::format_2(point_index)])]); + builder.add_table(&Gdef::new(None, None, Some(list), None)).expect("GDEF builds"); + for record in font.table_directory.table_records() { + let tag = Tag::new(&record.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(record.tag()).expect("base table reads").as_bytes()); + } + } + builder.build() +} + +#[test] +fn a_contour_point_caret_uses_its_unhinted_x_and_y() { + use skrifa::raw::TableProvider; + use skrifa::raw::tables::glyf::Glyph; + + let base = ligature_caret_font(None).expect("test font builds"); + let font = skrifa::FontRef::new(&base).expect("test font parses"); + let gid = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph")); + let outline = font.loca(None).expect("loca reads").get(skrifa::GlyphId::new(gid), &font.glyf().expect("glyf reads")).expect("glyph reads"); + let Glyph::Simple(simple) = outline.into_glyph().expect("glyph has an outline") else { panic!("W is simple") }; + let (point_index, point) = + simple.points().enumerate().find(|(_, p)| p.x > 0 && p.x < 500 && p.y > -500 && p.y < 1000).expect("W has an interior contour point"); + let db = db_with(Some(contour_caret_font(u16::try_from(point_index).unwrap()))); + let face = db.face(LIGATURE_CARET_FAMILY, "Regular").unwrap(); + assert_eq!(face.ligature_carets(gid, 1), Some(vec![f64::from(point.x)])); + assert_eq!(face.vertical_ligature_carets(gid, 1, 1000.0, 1500.0), Some(vec![1000.0 - f64::from(point.y)])); + + let bad = db_with(Some(contour_caret_font(u16::MAX))); + assert_eq!(bad.face(LIGATURE_CARET_FAMILY, "Regular").unwrap().ligature_carets(gid, 1), None); +} + +/// `count` glyph ids of [`contour_caret_font`] other than the ligature's. +fn spare_gids(count: usize) -> Vec { + let ligature = source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph"); + (1..).filter(|g| *g != ligature).take(count).collect() +} + +/// [`contour_caret_font`], whose caret is the ligature's point 0, with its `glyf` and `loca` +/// replaced: each `(gid, data)` in `glyphs` gives that glyph's `glyf` data, and every other glyph +/// is empty. +fn glyf_caret_font(glyphs: &[(u16, Vec)]) -> Vec { + use skrifa::raw::TableProvider; + use write_fonts::FontBuilder; + use write_fonts::types::Tag; + + let base = contour_caret_font(0); + let font = skrifa::FontRef::new(&base).expect("test font parses"); + let num_glyphs = font.maxp().expect("maxp reads").num_glyphs(); + let short_loca = font.head().expect("head reads").index_to_loc_format() == 0; + let mut glyf = Vec::new(); + let mut offsets = vec![0usize]; + for gid in 0..num_glyphs { + if let Some((_, data)) = glyphs.iter().find(|(g, _)| *g == gid) { + glyf.extend(data); + // Keep every offset even for the short `loca` format. + if glyf.len() % 2 == 1 { + glyf.push(0); + } + } + offsets.push(glyf.len()); + } + let loca: Vec = if short_loca { + offsets.iter().flat_map(|o| u16::try_from(o / 2).expect("short offset").to_be_bytes()).collect() + } else { + offsets.iter().flat_map(|o| u32::try_from(*o).expect("long offset").to_be_bytes()).collect() + }; + let mut builder = FontBuilder::new(); + builder.add_raw(Tag::new(b"glyf"), glyf); + builder.add_raw(Tag::new(b"loca"), loca); + for record in font.table_directory.table_records() { + let tag = Tag::new(&record.tag().to_be_bytes()); + if !builder.contains(tag) { + builder.add_raw(tag, font.table_data(record.tag()).expect("base table reads").as_bytes()); + } + } + builder.build() +} + +/// [`glyf_caret_font`] whose ligature is a composite of eight copies of a glyph that is itself +/// eight copies of the next, 15 levels down to a glyph with no contours. +fn composite_fan_caret_font() -> Vec { + const FAN: usize = 8; + let ligature = source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph"); + let levels = spare_gids(15); + let composite = |child: u16| { + let mut data = vec![0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0]; + for i in 0..FAN { + // ARGS_ARE_XY_VALUES, plus MORE_COMPONENTS on all but the last; byte offsets of 0. + let flags: u16 = if i + 1 < FAN { 0x0022 } else { 0x0002 }; + data.extend(flags.to_be_bytes()); + data.extend(child.to_be_bytes()); + data.extend([0, 0]); + } + data + }; + let mut glyphs = vec![(ligature, composite(levels[0]))]; + for (level, gid) in levels.iter().enumerate() { + match levels.get(level + 1) { + Some(child) => glyphs.push((*gid, composite(*child))), + // No contours, a zero bounding box and no instructions. + None => glyphs.push((*gid, vec![0; 12])), + } + } + glyf_caret_font(&glyphs) +} + +/// A contour-point caret on a composite's point uses the component offset the outline loader +/// uses. The ligature is one component, a glyph with a single point at (50, 0), turned a quarter +/// turn and scaled by 1.5 with a two-by-two matrix and placed at (100, 0). With +/// SCALED_COMPONENT_OFFSET the offset's x scales by the length of the x basis, 1.5, so the point +/// lands at x = 150; with UNSCALED_COMPONENT_OFFSET as well the offset stays (100, 0). +#[test] +fn a_contour_point_caret_on_a_composite_takes_its_scaled_offset() { + let gid = source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph"); + let [leaf] = spare_gids(1)[..] else { panic!("one spare glyph") }; + // One on-curve point at (50, 0) with word coordinates, and no instructions. + let mut point = vec![0, 1, 0, 0, 0, 0, 0, 50, 0, 0, 0, 0, 0, 0, 0x01]; + point.extend(50i16.to_be_bytes()); + point.extend(0i16.to_be_bytes()); + let composite = |offset_flags: u16| { + // ARG_1_AND_2_ARE_WORDS, ARGS_ARE_XY_VALUES and WE_HAVE_A_TWO_BY_TWO. + let flags = 0x0083 | offset_flags; + let mut data = vec![0xff, 0xff, 0, 0, 0, 0, 0, 0, 0, 0]; + data.extend(flags.to_be_bytes()); + data.extend(leaf.to_be_bytes()); + data.extend(100i16.to_be_bytes()); + data.extend(0i16.to_be_bytes()); + // xx = 0, yx = 1.5, xy = -1.5, yy = 0 in F2Dot14. + for f2dot14 in [0x0000u16, 0x6000, 0xa000, 0x0000] { + data.extend(f2dot14.to_be_bytes()); + } + data + }; + let caret = |offset_flags: u16| { + let db = db_with(Some(glyf_caret_font(&[(gid, composite(offset_flags)), (leaf, point.clone())]))); + db.face(LIGATURE_CARET_FAMILY, "Regular").unwrap().ligature_carets(u32::from(gid), 1) + }; + // SCALED_COMPONENT_OFFSET, then with UNSCALED_COMPONENT_OFFSET too. + assert_eq!(caret(0x0800), Some(vec![150.0])); + assert_eq!(caret(0x1800), Some(vec![100.0])); +} + +/// A contour-point caret on a composite that expands to billions of glyphs with no points gives +/// no caret, and quickly, rather than reading every one of them. +#[test] +fn a_contour_point_caret_on_a_huge_empty_composite_gives_up() { + let gid = u32::from(source_sans_gid(LIGATURE_CARET_GLYPH).expect("Source Sans 3 has the glyph")); + let (send, receive) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let db = db_with(Some(composite_fan_caret_font())); + let carets = db.face(LIGATURE_CARET_FAMILY, "Regular").map(|face| face.ligature_carets(gid, 1)); + let _ = send.send(carets); + }); + let carets = receive.recv_timeout(std::time::Duration::from_secs(30)).expect("the caret lookup finishes"); + assert_eq!(carets, Some(None)); +} + fn point(text: &str, family: &str) -> TextObject { TextObject::point(Point::ZERO, text, CharStyle { font_family: family.into(), size: 100.0, ..CharStyle::default() }) } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b4370a3a0..55dd26643 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,9 +1,11 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (ligature contour-point carets; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence +Ligature caret stops use a font's GDEF contour-point positions in horizontal and vertical text. They use unhinted outlines at the default variation location; invalid point indices use the existing evenly divided fallback. Scores and estimates are unchanged. + Creating a gradient from solid or empty paint uses the document's color model for its default stops, including drawing its vector with the Gradient tool. Explicit `keepModel: true` retains RGB defaults, and existing gradients keep their own colors. Scores and estimates are unchanged. **Feature evidence last updated:** 2026-11-03. @@ -21,6 +23,7 @@ Artboard Options forwards the active Artboard tool's Scale Artwork with Artboard | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Ligature caret stops use GDEF contour-point positions in horizontal and vertical type at the font's default variation location; invalid point indices keep the evenly divided fallback. Scores and estimates unchanged. | | 2026-11-03 | minor | New gradients use CMYK default stops in CMYK documents, including Gradient tool creation. Scores and estimates unchanged. | | 2026-11-03 | minor | Type Tools add-on: Text Tweaker (a VectorCraft addition, #4924): evidence added; scores unchanged. | | 2026-11-03 | minor | Transform previews valid typed values while numeric fields remain focused. Effect dialogs use the object, group, or layer targeted in Appearance. | @@ -775,7 +778,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur While a drag rotates the view, a dial at the center of the canvas shows the angle. - **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation). While a Type tool edits type, a click that lands on no type ends the editing without making new type, and that tool stays active; the next click makes new type (#2871, #2854). - Inside a ligature (one glyph for several characters, such as fi), the Type tool's caret, clicks, arrow keys and selection stop between its characters, at the font's ligature carets ([OpenType GDEF ligature caret list](https://learn.microsoft.com/en-us/typography/opentype/spec/gdef)) or, when the font does not list one valid caret for each stop, at the glyph's width divided equally. + Inside a ligature (one glyph for several characters, such as fi), the Type tool's caret, clicks, arrow keys and selection stop between its characters, at the font's ligature carets ([OpenType GDEF ligature caret list](https://learn.microsoft.com/en-us/typography/opentype/spec/gdef)) or, when the font does not list one valid caret for each stop, at the glyph's width divided equally. Contour-point carets use the font's unhinted outline at its default variation location; pixel-size device adjustments are not applied. Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides. - **Touch Type:** a click with the Touch Type tool (Shift+T) selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. Each drag is one undo step that sets the character's kerning, baseline shift, scales or rotation, and the type stays editable (`text.touchType`; behavior reference: [Touch Type tool](https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html), [Add text with the Type, Area Type, and Touch Type tools](https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html)). Composition's leading-space reduction stays along the line when a character rotates. An opening bracket trimmed by Line-end Punctuation Half Width Mojikumi keeps that placement under Character Rotation; its turning point follows the composition adjustment, and removing the rotation keeps the trim. Scores and estimates are unchanged.