mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 04:54:45 +00:00
Type: Align to Path moves each character by its own font's metrics
Align to Path moved the whole line by the largest ascent and descent among its characters. Each character now moves by its own font, size and vertical scale. Ascender runs the top of the font's em box along the path: the ideographic em box that the font's BASE table gives, or in a Chinese, Japanese or Korean font the one that its typographic or hhea box, a one-em box-drawing cross or its ideographs give; else an em box centered on the cap height. Descender runs the bottom of the font's bounding box along the path, and Center runs a line at half the height of the top of the font's H along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. Existing documents with Align to Path set to Ascender, Descender or Center lay their type out at a new distance from the path.
This commit is contained in:
@@ -101,15 +101,24 @@ impl PathEffect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options › Align to Path: which height of the type runs along the path.
|
||||
/// Type on a Path Options › Align to Path: which height of the type runs along the path. Each
|
||||
/// character is measured by its own font, size and vertical scale, and in vertical type by its own
|
||||
/// size and horizontal scale. An inline graphic moves as a character of its run would, by its
|
||||
/// run's vertical scale in vertical type too. These distances are VectorCraft's own values.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PathAlign {
|
||||
/// The font's top edge: the type hangs below the path.
|
||||
/// The top of the font's em box: the type hangs below the path. The em box is the font's
|
||||
/// ideographic em box (from its `BASE` table, its typographic or `hhea` box, or its
|
||||
/// ideographs), else an em box centered on the cap height, else a box whose top is 0.88 em
|
||||
/// above the baseline. In vertical type the characters move half an em.
|
||||
Ascender,
|
||||
/// The font's bottom edge: the type stands above the path.
|
||||
/// The bottom of the font's bounding box, else the font's descent: the type stands above the
|
||||
/// path. In vertical type the characters move half an em the other way.
|
||||
Descender,
|
||||
/// Halfway between the ascender and the descender.
|
||||
/// A line at half the height of the top of the font's H above the baseline, else at half its
|
||||
/// cap height, else at the middle of its em box. In vertical type the characters stay on the
|
||||
/// path.
|
||||
Center,
|
||||
/// The baseline (the default).
|
||||
#[default]
|
||||
|
||||
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type on a Path Options…",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
"{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
|
||||
@@ -4498,6 +4498,56 @@ fn aligned_type_round_a_circle_runs_along_its_path_as_the_file_lays_it_out() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type aligned to a circle by its ascender, center or descender, in the default face at 12 pt: the
|
||||
/// file's baselines lie inside the path by the top of the face's em box (its `BASE` table's
|
||||
/// `ideo` -170 plus one em), half the height of its H's top (656 units) or the bottom of its
|
||||
/// bounding box (-295), and laid out, the type's baselines lie where the file has them.
|
||||
#[test]
|
||||
fn aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out() {
|
||||
let k = 40.0 * 0.552_284_75;
|
||||
let points = [
|
||||
(90.0, 50.0),
|
||||
(90.0, 50.0 - k),
|
||||
(50.0 + k, 10.0),
|
||||
(50.0, 10.0),
|
||||
(50.0 - k, 10.0),
|
||||
(10.0, 50.0 - k),
|
||||
(10.0, 50.0),
|
||||
(10.0, 50.0 + k),
|
||||
(50.0 - k, 90.0),
|
||||
(50.0, 90.0),
|
||||
(50.0 + k, 90.0),
|
||||
(90.0, 50.0 + k),
|
||||
(90.0, 50.0),
|
||||
];
|
||||
let all: Vec<(f64, f64)> = points.iter().map(|(x, y)| canvas(*x, *y)).collect();
|
||||
let segment = |i: usize| (0..4).map(|j| format!("{} {}", all[i * 3 + j].0, all[i * 3 + j].1)).collect::<Vec<_>>().join(" ");
|
||||
for (key, align, inside) in [
|
||||
(0, vectorcraft_doc::PathAlign::Ascender, 0.83 * 12.0),
|
||||
(2, vectorcraft_doc::PathAlign::Center, 0.328 * 12.0),
|
||||
(1, vectorcraft_doc::PathAlign::Descender, -0.295 * 12.0),
|
||||
] {
|
||||
let frame = format!(
|
||||
"<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 2 /6 [ 2.0 5.99 ] /11 << /2 {key} >> >> >> >>",
|
||||
(0..4).map(segment).collect::<Vec<_>>().join(" ")
|
||||
);
|
||||
let radius = 40.0 - inside;
|
||||
let doc = document_of(&[frame], &[round_story_with("abcdefgh", 5.0, radius, radius / 48.0, &format!("/0 << /0 [ 0 {inside:.3} ] >>"))]);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.len(), 1, "{align:?}: {:?}", r.warnings);
|
||||
assert_eq!(texts[0].path_align, align);
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), texts[0]);
|
||||
assert_eq!(layout.glyphs.len(), 8);
|
||||
// The four cubics stray up to 0.011 from the circle.
|
||||
for g in &layout.glyphs {
|
||||
let (x, y) = (g.origin.x + g.angle.cos() * g.advance * 0.5 - 50.0, g.origin.y + g.angle.sin() * g.advance * 0.5 - 50.0);
|
||||
assert!((x.hypot(y) - radius).abs() < 0.02, "{align:?}: {} from the center, want {radius}", x.hypot(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threaded_area_type_flows_through_its_frames() {
|
||||
let frames = [
|
||||
|
||||
@@ -49,6 +49,7 @@ pub use dirs::{
|
||||
use face::Instances;
|
||||
#[cfg(test)]
|
||||
pub(crate) use face::MAX_LIVE_INSTANCES;
|
||||
pub(crate) use face::PathAlignHeights;
|
||||
pub use face::{FALLBACK_FAMILY, FontFace, IcfMargins};
|
||||
pub(crate) use glyph_pen::FlipPen;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -75,6 +75,7 @@ pub(super) fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Opt
|
||||
plans: std::sync::Mutex::new(Vec::new()),
|
||||
ideographic_centre: std::sync::OnceLock::new(),
|
||||
icf_margins: std::sync::OnceLock::new(),
|
||||
path_align: std::sync::OnceLock::new(),
|
||||
colour_bitmaps,
|
||||
colour_layers,
|
||||
svg_glyphs,
|
||||
|
||||
@@ -193,6 +193,8 @@ pub struct FontFace {
|
||||
pub(super) ideographic_centre: std::sync::OnceLock<f64>,
|
||||
/// [`Self::icf_margins`], read once.
|
||||
pub(super) icf_margins: std::sync::OnceLock<IcfMargins>,
|
||||
/// [`Self::path_align_heights`], read once.
|
||||
pub(super) path_align: std::sync::OnceLock<PathAlignHeights>,
|
||||
/// Has `sbix` or `CBDT` strikes: colour bitmap glyphs ([`FontDb::glyph_image`]).
|
||||
pub(super) colour_bitmaps: bool,
|
||||
/// Has `COLR` colour glyphs ([`FontDb::glyph_colour`]).
|
||||
@@ -212,6 +214,26 @@ pub struct IcfMargins {
|
||||
pub left: f64,
|
||||
}
|
||||
|
||||
/// The heights above the baseline, in ems, that Type on a Path's Align to Path runs along the
|
||||
/// path ([`FontFace::path_align_heights`]). These are VectorCraft's own values.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) struct PathAlignHeights {
|
||||
/// Ascender: the top of the face's em box ([`FontFace::em_box_top`]).
|
||||
pub(crate) top: f64,
|
||||
/// Descender (negative): the bottom of the font's bounding box (`head`), else the face's
|
||||
/// descent.
|
||||
pub(crate) bottom: f64,
|
||||
/// Center: half the height of the top of the H above the baseline in whole thousandths of an
|
||||
/// em, else half the OS/2 cap height, else the middle of the em box.
|
||||
pub(crate) center: f64,
|
||||
}
|
||||
|
||||
/// The characters whose ink an ideographic face's em box is centered on, the first the face has.
|
||||
const EM_BOX_IDEOGRAPHS: [char; 4] = ['永', '鬱', '곬', '・'];
|
||||
|
||||
/// A box-drawing character whose ink, in an ideographic face, is one em tall: the em box.
|
||||
const EM_BOX_CROSS: char = '┼';
|
||||
|
||||
impl std::fmt::Debug for FontFace {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "FontFace({} {})", self.family, self.style)
|
||||
@@ -462,6 +484,111 @@ impl FontFace {
|
||||
*self.icf_margins.get_or_init(|| self.icf_from_base().or_else(|| self.icf_from_glyphs()).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The lines Type on a Path's Align to Path runs along the path, in ems above the baseline
|
||||
/// ([`PathAlignHeights`]): the top of the em box ([`Self::em_box_top`], else 0.88 em), the
|
||||
/// bottom of the font's bounding box, and half the height of the top of the H.
|
||||
pub(crate) fn path_align_heights(&self) -> PathAlignHeights {
|
||||
*self.path_align.get_or_init(|| {
|
||||
use skrifa::raw::TableProvider;
|
||||
let em = self.upem;
|
||||
let top = self.em_box_top().unwrap_or(0.88);
|
||||
let bottom =
|
||||
self.skrifa().and_then(|f| f.head().ok()).map(|h| f64::from(h.y_min()) / em).filter(|b| b.is_finite() && *b <= 0.0 && *b > -4.0);
|
||||
// Measured from the baseline to the top of the H, as a cap height is, wherever its ink starts.
|
||||
let cap = self.glyph_ink('H').map(|(_, top)| top / em).or_else(|| self.os2_cap_height()).filter(|c| c.is_finite() && *c < 2.0);
|
||||
PathAlignHeights {
|
||||
top,
|
||||
bottom: bottom.unwrap_or(-self.descent / em),
|
||||
center: cap.map_or(top - 0.5, |cap| (cap * 1000.0).round() / 2000.0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// The top of the face's em box, in ems above the baseline. A `BASE` table with an `ideo`
|
||||
/// baseline gives it: its `idtp`, else one em above `ideo`. In a face whose OS/2 code pages
|
||||
/// include Chinese, Japanese or Korean, it is the typographic ascender of the typographic box
|
||||
/// (else, in a TrueType face, of the `hhea` box) when that box holds the ink of the first of 永,
|
||||
/// 鬱, 곬 and ・ the face has, and is one em tall or leaves equal margins above and below the
|
||||
/// ink; a face with none of them needs the box to be one em tall. Else it is the top of a ┼
|
||||
/// one em tall, else the top of an em box centered on the ink. In any other face the em box is
|
||||
/// centered on the cap height: the OS/2 value, else the lower of the tops of H and O in whole
|
||||
/// thousandths of an em. `None` when none of these applies.
|
||||
pub(crate) fn em_box_top(&self) -> Option<f64> {
|
||||
use skrifa::raw::TableProvider;
|
||||
let f = self.skrifa()?;
|
||||
let em = self.upem;
|
||||
let sane = |v: f64| (v.is_finite() && v.abs() < 4.0).then_some(v);
|
||||
// A roman baseline below the ideographic one is a damaged table: the baselines are ignored.
|
||||
if let Some(h) = f.base().ok().and_then(|b| b.horiz_axis()).and_then(Result::ok).and_then(base_values)
|
||||
&& let Some(&ideo) = h.get(b"ideo")
|
||||
&& h.get(b"romn").is_none_or(|&romn| romn >= ideo)
|
||||
{
|
||||
return sane(h.get(b"idtp").copied().unwrap_or(ideo + em) / em);
|
||||
}
|
||||
// ulCodePageRange1 bits 17 to 21: JIS/Japan, Chinese Simplified, Korean Wansung, Chinese
|
||||
// Traditional and Korean Johab.
|
||||
let cjk = f.os2().ok().and_then(|o| o.ul_code_page_range_1()).is_some_and(|r| r & (0b1_1111 << 17) != 0);
|
||||
if !cjk {
|
||||
let lower_top = || {
|
||||
let ((_, h), (_, o)) = (self.glyph_ink('H')?, self.glyph_ink('O')?);
|
||||
Some((h.min(o) / em * 1000.0).round() / 1000.0)
|
||||
};
|
||||
let cap = self.os2_cap_height().or_else(lower_top).filter(|c| c.is_finite() && *c > 0.0 && *c < 2.0)?;
|
||||
return Some((cap + 1.0) * 0.5);
|
||||
}
|
||||
// Heights in 65,536ths of an em, as the em box is kept.
|
||||
let units = |v: f64| (v / em * 65536.0).round();
|
||||
let ink = EM_BOX_IDEOGRAPHS.into_iter().find_map(|c| self.glyph_ink(c));
|
||||
// A variable font's MVAR table moves the OS/2 typographic ascender (`hasc`) and descender
|
||||
// (`hdsc`); it has no tags for the `hhea` ascender and descender, which do not vary:
|
||||
// https://learn.microsoft.com/en-us/typography/opentype/spec/mvar
|
||||
let varied = |tag: &[u8; 4]| {
|
||||
f.mvar().ok().and_then(|m| m.metric_delta(skrifa::Tag::new(tag), self.location().coords()).ok()).map_or(0.0, |d| d.to_f64())
|
||||
};
|
||||
let typo = f
|
||||
.os2()
|
||||
.ok()
|
||||
.map(|o| (f64::from(o.s_typo_ascender()) + varied(b"hasc"), f64::from(o.s_typo_descender()) + varied(b"hdsc")))
|
||||
.filter(|(ascent, descent)| ascent > descent)
|
||||
.or_else(|| {
|
||||
let truetype = f.table_data(skrifa::Tag::new(b"glyf")).is_some();
|
||||
f.hhea().ok().filter(|_| truetype).map(|h| (f64::from(h.ascender().to_i16()), f64::from(h.descender().to_i16())))
|
||||
})
|
||||
.filter(|(ascent, descent)| ascent > descent);
|
||||
if let Some((ascent, descent)) = typo {
|
||||
let one_em = units(ascent - descent) == 65536.0;
|
||||
let holds = match ink {
|
||||
None => one_em,
|
||||
Some((lo, hi)) => hi <= ascent && lo >= descent && (one_em || units((ascent - hi) - (lo - descent)).abs() <= 197.0),
|
||||
};
|
||||
if holds {
|
||||
return sane(ascent / em);
|
||||
}
|
||||
}
|
||||
if let Some((lo, hi)) = self.glyph_ink(EM_BOX_CROSS)
|
||||
&& (65535.0..=65537.0).contains(&units(hi - lo))
|
||||
{
|
||||
return sane(hi / em);
|
||||
}
|
||||
ink.and_then(|(lo, hi)| sane((lo + hi) / em * 0.5 + 0.5))
|
||||
}
|
||||
|
||||
/// The bottom and top of the ink of `c`'s glyph, in font units; `None` when the face has no
|
||||
/// glyph for it, or the glyph has no ink.
|
||||
fn glyph_ink(&self, c: char) -> Option<(f64, f64)> {
|
||||
let g = self.glyph_for(c);
|
||||
if g == 0 {
|
||||
return None;
|
||||
}
|
||||
let b = self.skrifa()?.glyph_metrics(Size::unscaled(), self.location()).bounds(GlyphId::new(g))?;
|
||||
Some((f64::from(b.y_min), f64::from(b.y_max))).filter(|(lo, hi)| hi > lo)
|
||||
}
|
||||
|
||||
/// The cap height of the OS/2 table, in ems; `None` without one.
|
||||
fn os2_cap_height(&self) -> Option<f64> {
|
||||
self.skrifa()?.metrics(Size::unscaled(), self.location()).cap_height.map(|c| f64::from(c) / self.upem).filter(|c| *c > 0.0)
|
||||
}
|
||||
|
||||
/// The height of the face that a font size measures with `reference`, in ems: 1 for the em
|
||||
/// box; the cap height or x height of its OS/2 table; the height of its ideographic character
|
||||
/// face ([`Self::icf_margins`]), or across a `vertical` line its width. None for a face whose
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
Affine, ArcPath, BezPath, Ctx, GlyphBend, Justify, LineInfo, ListMarker, ParamCurve, PathAlign, PathData, PathEffect, PathEl, Point, Range,
|
||||
SGlyph, Shape, TextObject, Vec2, style_metrics,
|
||||
};
|
||||
use crate::fontdb::PathAlignHeights;
|
||||
|
||||
/// The distance along type on a path each glyph takes: its advance measured `spacing` points above
|
||||
/// the path (Type on a Path Options › Spacing), so glyphs close up round the outside of a curve and
|
||||
@@ -315,16 +316,25 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject,
|
||||
cx.out.overflow = true;
|
||||
fits = sg.get(fit..).and_then(|rest| rest.iter().map(|g| g.byte).min()).unwrap_or(text_len);
|
||||
}
|
||||
// Align to Path: how far down (glyph space) the type moves to run its ascender, centre or
|
||||
// descender along the path instead of its baseline.
|
||||
let rise = match t.path_align {
|
||||
PathAlign::Baseline => 0.0,
|
||||
PathAlign::Ascender => m.0,
|
||||
PathAlign::Descender => -m.1,
|
||||
PathAlign::Center => (m.0 - m.1) * 0.5,
|
||||
// Align to Path: how far down (glyph space) a glyph moves to run the top of its em box, half the
|
||||
// height of the top of its H or the bottom of its font's bounding box along the path instead of its
|
||||
// baseline, each glyph by its own face, size and vertical scale. In vertical type the glyph
|
||||
// moves half its em (with its horizontal scale) for Ascender and Descender, and stays for Center.
|
||||
// An inline graphic moves as a character of its run would, by its run's vertical scale in both.
|
||||
let vertical = cx.vertical;
|
||||
let rise_of = |g: &SGlyph| {
|
||||
let em = g.face.units_per_em() * if vertical { g.sx } else { g.sy };
|
||||
let h = if vertical { PathAlignHeights { top: 0.5, bottom: -0.5, center: 0.0 } } else { g.face.path_align_heights() };
|
||||
match t.path_align {
|
||||
PathAlign::Baseline => 0.0,
|
||||
PathAlign::Ascender => h.top * em,
|
||||
PathAlign::Descender => h.bottom * em,
|
||||
PathAlign::Center => h.center * em,
|
||||
}
|
||||
};
|
||||
let mut x = 0.0;
|
||||
for (g, &step) in sg.iter().zip(&steps).take(fit) {
|
||||
let rise = rise_of(g);
|
||||
let s = s0 + x;
|
||||
let (p, dir) = ap.at(s + step * 0.5);
|
||||
let angle = dir.y.atan2(dir.x);
|
||||
|
||||
@@ -1073,6 +1073,160 @@ pub fn tall_ideograph_font() -> Option<Vec<u8>> {
|
||||
Some(data)
|
||||
}
|
||||
|
||||
/// A family of [`path_align_font`]: OS/2 cap height 660, H and O.
|
||||
pub const PATH_ALIGN_ROMAN_FAMILY: &str = "Pathalign Roman Sans";
|
||||
/// A family of [`path_align_font`]: no cap height, no H or O, and a `head` box above the baseline.
|
||||
pub const PATH_ALIGN_BARE_FAMILY: &str = "Pathalign Bare Sans";
|
||||
/// A family of [`path_align_font`]: a `BASE` table with `ideo` at -150 and no `idtp`.
|
||||
pub const PATH_ALIGN_BASE_FAMILY: &str = "Pathalign Base Sans";
|
||||
/// A family of [`path_align_font`]: Japanese code page, 永 on the ⌠ outline (ink -51 to 949).
|
||||
pub const PATH_ALIGN_IDEOGRAPH_FAMILY: &str = "Pathalign Ideograph Sans";
|
||||
/// A family of [`path_align_font`]: as [`PATH_ALIGN_IDEOGRAPH_FAMILY`], with ┼ on the | outline
|
||||
/// (ink -250 to 750, one em).
|
||||
pub const PATH_ALIGN_CROSS_FAMILY: &str = "Pathalign Cross Sans";
|
||||
/// A family of [`path_align_font`]: Japanese code page, 永 on the H outline (ink 0 to 656), and a
|
||||
/// typographic box from -100 to 900.
|
||||
pub const PATH_ALIGN_TYPO_FAMILY: &str = "Pathalign Typo Sans";
|
||||
/// A family of [`path_align_font`]: as [`PATH_ALIGN_TYPO_FAMILY`], with a typographic box of 0
|
||||
/// to 0 and an `hhea` box from -100 to 900.
|
||||
pub const PATH_ALIGN_HHEA_FAMILY: &str = "Pathalign Hhea Sans";
|
||||
/// A family of [`path_align_font`]: as [`PATH_ALIGN_TYPO_FAMILY`], as a variable font with one
|
||||
/// weight axis (400–700, named instances Regular and Bold) whose `MVAR` table raises the
|
||||
/// typographic ascender and descender by 100 at 700: a box from 0 to 1,000 in Bold.
|
||||
pub const PATH_ALIGN_VARIABLE_FAMILY: &str = "Pathalign Variable Sans";
|
||||
/// A family of [`path_align_font`]: Japanese code page, with no reference ideograph and no ┼.
|
||||
pub const PATH_ALIGN_NONE_FAMILY: &str = "Pathalign None Sans";
|
||||
/// A family of [`path_align_font`]: no cap height, and H and O swapped (H top 668, O top 656).
|
||||
pub const PATH_ALIGN_LOWER_FAMILY: &str = "Pathalign Lower Sans";
|
||||
/// A family of [`path_align_font`]: OS/2 cap height 660, O and no H, and a `BASE` table as
|
||||
/// [`PATH_ALIGN_BASE_FAMILY`]'s.
|
||||
pub const PATH_ALIGN_CAPITAL_FAMILY: &str = "Pathalign Capital Sans";
|
||||
/// A family of [`path_align_font`]: as [`PATH_ALIGN_IDEOGRAPH_FAMILY`], with a typographic box
|
||||
/// from -101 to 1,002: margins of 53 and 50 round 永's ink.
|
||||
pub const PATH_ALIGN_MARGINS_FAMILY: &str = "Pathalign Margins Sans";
|
||||
/// A family of [`path_align_font`]: as [`PATH_ALIGN_IDEOGRAPH_FAMILY`], with a typographic box
|
||||
/// from -101 to 1,003: margins of 54 and 50 round 永's ink.
|
||||
pub const PATH_ALIGN_UNEVEN_FAMILY: &str = "Pathalign Uneven Sans";
|
||||
/// A family of [`path_align_font`]: a `BASE` table with `ideo` at -150 and `romn` below it at
|
||||
/// -200.
|
||||
pub const PATH_ALIGN_DAMAGED_FAMILY: &str = "Pathalign Damaged Sans";
|
||||
|
||||
/// The bundled Source Sans 3 Regular (1,000 units to the em, typographic box -326 to 1,000, `head`
|
||||
/// box bottom -295, H top 656, O top 668) without its `BASE` table, as one of the
|
||||
/// `PATH_ALIGN_*_FAMILY` faces, which map x, and H and O except where the family says otherwise.
|
||||
/// `None` when it can't be built.
|
||||
pub fn path_align_font(family: &str) -> Option<Vec<u8>> {
|
||||
use write_fonts::tables::base::{Axis, Base, BaseCoord, BaseScript, BaseScriptList, BaseScriptRecord, BaseTagList, BaseValues};
|
||||
use write_fonts::tables::mvar::{Mvar, ValueRecord};
|
||||
use write_fonts::tables::variations::{ItemVariationData, ItemVariationStore, RegionAxisCoordinates, VariationRegion, VariationRegionList};
|
||||
use write_fonts::types::MajorMinor;
|
||||
|
||||
let typo = family == PATH_ALIGN_TYPO_FAMILY || family == PATH_ALIGN_VARIABLE_FAMILY;
|
||||
let ideograph = [PATH_ALIGN_IDEOGRAPH_FAMILY, PATH_ALIGN_CROSS_FAMILY, PATH_ALIGN_MARGINS_FAMILY, PATH_ALIGN_UNEVEN_FAMILY].contains(&family);
|
||||
let japanese = typo || ideograph || [PATH_ALIGN_HHEA_FAMILY, PATH_ALIGN_NONE_FAMILY].contains(&family);
|
||||
let mut mapping = vec![('x', 'x')];
|
||||
match family {
|
||||
PATH_ALIGN_BARE_FAMILY => {}
|
||||
PATH_ALIGN_LOWER_FAMILY => mapping.extend([('H', 'O'), ('O', 'H')]),
|
||||
PATH_ALIGN_CAPITAL_FAMILY => mapping.push(('O', 'O')),
|
||||
_ => mapping.extend([('H', 'H'), ('O', 'O')]),
|
||||
}
|
||||
if ideograph {
|
||||
mapping.push(('永', '⌠'));
|
||||
}
|
||||
if family == PATH_ALIGN_CROSS_FAMILY {
|
||||
mapping.push(('┼', '|'));
|
||||
}
|
||||
if typo || family == PATH_ALIGN_HHEA_FAMILY {
|
||||
mapping.push(('永', 'H'));
|
||||
}
|
||||
let mut data = remapped_source_sans(family, &format!("{}-Regular", family.replace(' ', "")), &mapping)?;
|
||||
let tables: Vec<(Tag, usize)> = {
|
||||
let font = skrifa::FontRef::new(&data).ok()?;
|
||||
font.table_directory.table_records().iter().map(|r| (Tag::new(&r.tag().to_be_bytes()), r.offset() as usize)).collect()
|
||||
};
|
||||
for (tag, at) in tables {
|
||||
if tag == Tag::new(b"OS/2") {
|
||||
if japanese {
|
||||
// ulCodePageRange1 at bytes 78 to 82: bit 17, JIS/Japan.
|
||||
data.get_mut(at + 78..at + 82)?.copy_from_slice(&(1_u32 << 17).to_be_bytes());
|
||||
}
|
||||
if typo {
|
||||
// sTypoAscender and sTypoDescender at bytes 68 to 72.
|
||||
data.get_mut(at + 68..at + 72)?.copy_from_slice(&[900_i16.to_be_bytes(), (-100_i16).to_be_bytes()].concat());
|
||||
}
|
||||
if family == PATH_ALIGN_HHEA_FAMILY {
|
||||
data.get_mut(at + 68..at + 72)?.copy_from_slice(&[0, 0, 0, 0]);
|
||||
}
|
||||
if family == PATH_ALIGN_MARGINS_FAMILY || family == PATH_ALIGN_UNEVEN_FAMILY {
|
||||
let top: i16 = if family == PATH_ALIGN_MARGINS_FAMILY { 1002 } else { 1003 };
|
||||
data.get_mut(at + 68..at + 72)?.copy_from_slice(&[top.to_be_bytes(), (-101_i16).to_be_bytes()].concat());
|
||||
}
|
||||
if family == PATH_ALIGN_BARE_FAMILY || family == PATH_ALIGN_LOWER_FAMILY {
|
||||
// sCapHeight at bytes 88 to 90.
|
||||
data.get_mut(at + 88..at + 90)?.copy_from_slice(&0_i16.to_be_bytes());
|
||||
}
|
||||
} else if tag == Tag::new(b"hhea") && family == PATH_ALIGN_HHEA_FAMILY {
|
||||
// ascender and descender at bytes 4 to 8.
|
||||
data.get_mut(at + 4..at + 8)?.copy_from_slice(&[900_i16.to_be_bytes(), (-100_i16).to_be_bytes()].concat());
|
||||
} else if tag == Tag::new(b"head") && family == PATH_ALIGN_BARE_FAMILY {
|
||||
// yMin at bytes 38 to 40.
|
||||
data.get_mut(at + 38..at + 40)?.copy_from_slice(&10_i16.to_be_bytes());
|
||||
}
|
||||
}
|
||||
// Source Sans 3's own `BASE` table (`ideo` -170) is left out.
|
||||
let font = skrifa::FontRef::new(&data).ok()?;
|
||||
let mut builder = FontBuilder::new();
|
||||
if [PATH_ALIGN_BASE_FAMILY, PATH_ALIGN_CAPITAL_FAMILY, PATH_ALIGN_DAMAGED_FAMILY].contains(&family) {
|
||||
let romn = if family == PATH_ALIGN_DAMAGED_FAMILY { -200 } else { 0 };
|
||||
let tags = BaseTagList::new([b"ideo", b"romn"].map(Tag::new).to_vec());
|
||||
let values = BaseValues::new(1, [-150, romn].map(BaseCoord::format_1).to_vec());
|
||||
let latn = BaseScriptRecord::new(Tag::new(b"latn"), BaseScript::new(Some(values), None, vec![]));
|
||||
let axis = Axis::new(Some(tags), BaseScriptList::new(vec![latn]));
|
||||
builder.add_table(&Base::new(Some(axis), None)).ok()?;
|
||||
}
|
||||
if family == PATH_ALIGN_VARIABLE_FAMILY {
|
||||
let region = VariationRegion::new(vec![RegionAxisCoordinates::new(F2Dot14::from_f32(0.0), F2Dot14::from_f32(1.0), F2Dot14::from_f32(1.0))]);
|
||||
// Two delta sets (hasc, hdsc) of one 16-bit delta each for the one region.
|
||||
let data = ItemVariationData::new(2, 1, vec![0], [100_i16.to_be_bytes(), 100_i16.to_be_bytes()].concat());
|
||||
let store = ItemVariationStore::new(VariationRegionList::new(1, vec![region]), vec![Some(data)]);
|
||||
let records = vec![ValueRecord::new(Tag::new(b"hasc"), 0, 0), ValueRecord::new(Tag::new(b"hdsc"), 0, 1)];
|
||||
let axis = VariationAxisRecord {
|
||||
axis_tag: Tag::new(b"wght"),
|
||||
min_value: Fixed::from_i32(400),
|
||||
default_value: Fixed::from_i32(400),
|
||||
max_value: Fixed::from_i32(700),
|
||||
flags: 0,
|
||||
axis_name_id: NameId::new(256),
|
||||
};
|
||||
let instance = |name: u16, wght: i32| InstanceRecord {
|
||||
subfamily_name_id: NameId::new(name),
|
||||
flags: 0,
|
||||
coordinates: vec![Fixed::from_i32(wght)],
|
||||
post_script_name_id: None,
|
||||
};
|
||||
let fvar = Fvar::new(AxisInstanceArrays::new(vec![axis], vec![instance(257, 400), instance(258, 700)]));
|
||||
let name = name_table(&[
|
||||
(1, family),
|
||||
(2, "Regular"),
|
||||
(4, "Pathalign Variable Sans Regular"),
|
||||
(6, "PathalignVariableSans-Regular"),
|
||||
(256, "Weight"),
|
||||
(257, "Regular"),
|
||||
(258, "Bold"),
|
||||
])?;
|
||||
builder.add_raw(Tag::new(b"name"), name);
|
||||
builder.add_table(&fvar).ok()?.add_table(&Mvar::new(MajorMinor::VERSION_1_0, Some(store), records)).ok()?;
|
||||
}
|
||||
for r in font.table_directory.table_records() {
|
||||
let tag = Tag::new(&r.tag().to_be_bytes());
|
||||
if !builder.contains(tag) && tag != Tag::new(b"BASE") {
|
||||
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
|
||||
}
|
||||
}
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
/// The family of [`soft_hyphen_outline_font`].
|
||||
pub const SOFT_HYPHEN_OUTLINE_FAMILY: &str = "Soft Hyphen Profile Sans";
|
||||
|
||||
|
||||
@@ -455,6 +455,158 @@ fn on_path_effects_orient_glyphs() {
|
||||
assert_eq!(PathEffect::parse("stair step"), Some(PathEffect::StairStep));
|
||||
}
|
||||
|
||||
/// Type on a straight path from (0, 50) to (500, 50), one run per style: how far each glyph's
|
||||
/// baseline lies below the path under each Align to Path option, or with `ink` the middle of its
|
||||
/// drawn outline.
|
||||
fn path_align_drops(db: &FontDb, styles: &[CharStyle], vertical: bool, ink: bool) -> Vec<(vectorcraft_doc::PathAlign, Vec<f64>)> {
|
||||
use vectorcraft_doc::PathAlign;
|
||||
let mut line = BezPath::new();
|
||||
line.move_to((0.0, 50.0));
|
||||
line.line_to((500.0, 50.0));
|
||||
let mut t = point("", style(12.0));
|
||||
t.runs = styles.iter().map(|st| TextRun::new("x", st.clone())).collect();
|
||||
t.kind = TextKind::OnPath { path: PathData::from_bezpath(&line), start: 0.0, end: None };
|
||||
t.vertical = vertical;
|
||||
[PathAlign::Baseline, PathAlign::Ascender, PathAlign::Center, PathAlign::Descender]
|
||||
.into_iter()
|
||||
.map(|align| {
|
||||
t.path_align = align;
|
||||
let drop = |g: &PositionedGlyph| {
|
||||
let b = g.outline.bounding_box();
|
||||
if ink { (b.y0 + b.y1) * 0.5 - 50.0 } else { g.origin.y - 50.0 }
|
||||
};
|
||||
(align, layout(db, &t).glyphs.iter().map(drop).collect())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Align to Path moves each glyph's baseline off the path by its own face, size and vertical
|
||||
/// scale: Ascender by the top of an em box centered on the cap height, Center by half the height
|
||||
/// of the top of the H in whole thousandths of an em, Descender up by the bottom of the font's
|
||||
/// bounding box. Without an OS/2 cap height, the em box is centered on the lower of the tops of H
|
||||
/// and O; without an H, Center takes half the OS/2 cap height. A face with no cap height and no H
|
||||
/// or O takes an em box from 0.12 em below the baseline to 0.88 em above it and, with a bounding
|
||||
/// box above the baseline, its descent. Leading and horizontal scale do not count.
|
||||
#[test]
|
||||
fn on_path_align_runs_each_glyph_by_its_own_metrics() {
|
||||
use crate::test_fonts::{PATH_ALIGN_BARE_FAMILY, PATH_ALIGN_CAPITAL_FAMILY, PATH_ALIGN_LOWER_FAMILY, PATH_ALIGN_ROMAN_FAMILY, path_align_font};
|
||||
use vectorcraft_doc::PathAlign;
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
for family in [PATH_ALIGN_ROMAN_FAMILY, PATH_ALIGN_BARE_FAMILY, PATH_ALIGN_LOWER_FAMILY, PATH_ALIGN_CAPITAL_FAMILY] {
|
||||
db.add_font(path_align_font(family).unwrap());
|
||||
}
|
||||
let bare = db.face(PATH_ALIGN_BARE_FAMILY, "Regular").unwrap();
|
||||
let bare_descent = bare.vertical_metrics().1 / bare.units_per_em();
|
||||
let st = |family: &str, size: f64| CharStyle { font_family: family.into(), ..style(size) };
|
||||
let styles = [
|
||||
st(PATH_ALIGN_ROMAN_FAMILY, 20.0),
|
||||
st(PATH_ALIGN_ROMAN_FAMILY, 40.0),
|
||||
CharStyle { v_scale: 200.0, ..st(PATH_ALIGN_ROMAN_FAMILY, 20.0) },
|
||||
CharStyle { h_scale: 200.0, leading: Some(90.0), ..st(PATH_ALIGN_ROMAN_FAMILY, 20.0) },
|
||||
st(PATH_ALIGN_BARE_FAMILY, 20.0),
|
||||
st(PATH_ALIGN_LOWER_FAMILY, 20.0),
|
||||
st(PATH_ALIGN_CAPITAL_FAMILY, 20.0),
|
||||
];
|
||||
// Per em: the cap height is 0.66 em, the H 0.656 em tall, the bounding box's bottom -0.295 em;
|
||||
// the lower face's H is 0.668 em tall and its O 0.656 em; the capital face's em box runs from
|
||||
// its `BASE` table's `ideo` at -0.15 em.
|
||||
for (align, drops) in path_align_drops(&db, &styles, false, false) {
|
||||
let (roman, bare, lower, capital) = match align {
|
||||
PathAlign::Baseline => (0.0, 0.0, 0.0, 0.0),
|
||||
PathAlign::Ascender => ((0.66 + 1.0) / 2.0, 0.88, (0.656 + 1.0) / 2.0, 0.85),
|
||||
PathAlign::Center => (0.328, 0.38, 0.334, 0.33),
|
||||
PathAlign::Descender => (-0.295, -bare_descent, -0.295, -0.295),
|
||||
};
|
||||
let want = [roman * 20.0, roman * 40.0, roman * 40.0, roman * 20.0, bare * 20.0, lower * 20.0, capital * 20.0];
|
||||
assert!(drops.len() == 7 && drops.iter().zip(want).all(|(d, w)| (d - w).abs() < 1e-6), "{align:?}: {drops:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The em box whose top Ascender runs along the path, by the kind of face: a `BASE` table's
|
||||
/// ideographic baseline, unless its roman baseline lies below it; in a face with a Chinese,
|
||||
/// Japanese or Korean code page, a typographic box that holds the ink of 永 and is one em tall or
|
||||
/// leaves margins within 197/65,536 em of each other, else a ┼ one em tall, else an em box
|
||||
/// centered on the ink of 永; else 0.88 em. Center measures the H, not the OS/2 cap height (0.7 em
|
||||
/// in the heights face).
|
||||
#[test]
|
||||
fn on_path_align_finds_the_em_box_of_each_kind_of_face() {
|
||||
use crate::test_fonts::{
|
||||
HEIGHT_FAMILY, PATH_ALIGN_BASE_FAMILY, PATH_ALIGN_CROSS_FAMILY, PATH_ALIGN_DAMAGED_FAMILY, PATH_ALIGN_HHEA_FAMILY,
|
||||
PATH_ALIGN_IDEOGRAPH_FAMILY, PATH_ALIGN_MARGINS_FAMILY, PATH_ALIGN_NONE_FAMILY, PATH_ALIGN_TYPO_FAMILY, PATH_ALIGN_UNEVEN_FAMILY,
|
||||
height_font, path_align_font,
|
||||
};
|
||||
use vectorcraft_doc::PathAlign;
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let families = [
|
||||
PATH_ALIGN_BASE_FAMILY,
|
||||
PATH_ALIGN_IDEOGRAPH_FAMILY,
|
||||
PATH_ALIGN_CROSS_FAMILY,
|
||||
PATH_ALIGN_TYPO_FAMILY,
|
||||
PATH_ALIGN_HHEA_FAMILY,
|
||||
PATH_ALIGN_NONE_FAMILY,
|
||||
PATH_ALIGN_MARGINS_FAMILY,
|
||||
PATH_ALIGN_UNEVEN_FAMILY,
|
||||
PATH_ALIGN_DAMAGED_FAMILY,
|
||||
];
|
||||
for family in families {
|
||||
db.add_font(path_align_font(family).unwrap());
|
||||
}
|
||||
db.add_font(height_font(true).unwrap());
|
||||
let styles: Vec<CharStyle> = families.iter().chain([&HEIGHT_FAMILY]).map(|f| CharStyle { font_family: (*f).into(), ..style(10.0) }).collect();
|
||||
let drops = path_align_drops(&db, &styles, false, false);
|
||||
let of = |align: PathAlign| drops.iter().find(|(a, _)| *a == align).map(|(_, d)| d.clone()).unwrap();
|
||||
// BASE -150 + one em; Japanese: the ⌠'s ink -51 to 949 centered, the | from -250 to 750, the
|
||||
// typographic box -100 to 900, the `hhea` box -100 to 900 under an empty typographic box,
|
||||
// nothing, the typographic box -101 to 1,002 (margins 3/1,000 em apart), the ink centered
|
||||
// under a box -101 to 1,003 (4/1,000 em apart); a damaged BASE table's face by its cap height
|
||||
// 660; the heights face's BASE `idtp` 880.
|
||||
let want = [0.85, 0.949, 0.75, 0.9, 0.9, 0.88, 1.002, 0.949, 0.83, 0.88].map(|em| em * 10.0);
|
||||
let ascender = of(PathAlign::Ascender);
|
||||
assert!(ascender.len() == 10 && ascender.iter().zip(want).all(|(d, w)| (d - w).abs() < 1e-6), "{ascender:?}, want {want:?}");
|
||||
let center = of(PathAlign::Center);
|
||||
assert!((center[9] - 3.28).abs() < 1e-6, "{center:?}");
|
||||
}
|
||||
|
||||
/// A variable font's instance takes its em box from the typographic box its `MVAR` table varies:
|
||||
/// from -100 to 900 in Regular, from 0 to 1,000 in Bold.
|
||||
#[test]
|
||||
fn on_path_align_takes_a_variable_font_instances_em_box() {
|
||||
use crate::test_fonts::{PATH_ALIGN_VARIABLE_FAMILY, path_align_font};
|
||||
use vectorcraft_doc::PathAlign;
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(path_align_font(PATH_ALIGN_VARIABLE_FAMILY).unwrap());
|
||||
let st = |font_style: &str| CharStyle { font_family: PATH_ALIGN_VARIABLE_FAMILY.into(), font_style: font_style.into(), ..style(10.0) };
|
||||
let drops = path_align_drops(&db, &[st("Regular"), st("Bold")], false, false);
|
||||
let ascender = drops.iter().find(|(a, _)| *a == PathAlign::Ascender).map(|(_, d)| d.clone()).unwrap();
|
||||
assert!(ascender.len() == 2 && (ascender[0] - 9.0).abs() < 1e-6 && (ascender[1] - 10.0).abs() < 1e-6, "{ascender:?}");
|
||||
}
|
||||
|
||||
/// In vertical type, Align to Path moves the glyphs half an em, with their horizontal scale and not
|
||||
/// their vertical scale, for Ascender and Descender, and leaves them on the path for Center: their
|
||||
/// baselines and their drawn outlines both.
|
||||
#[test]
|
||||
fn on_path_align_in_vertical_type_moves_half_an_em() {
|
||||
use crate::test_fonts::{PATH_ALIGN_ROMAN_FAMILY, path_align_font};
|
||||
use vectorcraft_doc::PathAlign;
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
db.add_font(path_align_font(PATH_ALIGN_ROMAN_FAMILY).unwrap());
|
||||
let st = CharStyle { font_family: PATH_ALIGN_ROMAN_FAMILY.into(), ..style(20.0) };
|
||||
let styles = [st.clone(), CharStyle { h_scale: 200.0, ..st.clone() }, CharStyle { v_scale: 200.0, ..st }];
|
||||
for ink in [false, true] {
|
||||
let baseline = path_align_drops(&db, &styles, true, ink).first().map(|(_, d)| d.clone()).unwrap();
|
||||
for (align, drops) in path_align_drops(&db, &styles, true, ink) {
|
||||
let half = match align {
|
||||
PathAlign::Ascender => 10.0,
|
||||
PathAlign::Descender => -10.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let want = [half, half * 2.0, half];
|
||||
let moved: Vec<f64> = drops.iter().zip(&baseline).map(|(d, b)| d - b).collect();
|
||||
assert!(moved.len() == 3 && moved.iter().zip(want).all(|(m, w)| (m - w).abs() < 1e-6), "{align:?}, ink {ink}: {moved:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn on_path_circle_and_overflow() {
|
||||
let circle = kurbo::Circle::new((0.0, 0.0), 100.0).to_path(0.1);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -187,7 +187,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
## G9 Illustrator `.ai` and EPS files from real users
|
||||
|
||||
- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path comes from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged.
|
||||
- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged.
|
||||
|
||||
- **Save-back update (2026-10-26):** ordinary EPS Save retains page margins and the whole editable document; source-loss confirmation remains. Import fidelity gaps and estimates below are unchanged.
|
||||
|
||||
|
||||
+6
-2
@@ -6269,8 +6269,12 @@ Type on a path flows between a start and an end bracket, stored as fractions of
|
||||
Path Options: `end: null` puts the end bracket back at the end of the path (or once round a closed one); `flip`
|
||||
then turns the type to the other side of its path, the path running the other way and the brackets swapping ends so
|
||||
the type keeps its stretch of the path; `alignToPath` runs the `ascender`, `descender`, `center` or `baseline` (the
|
||||
default) along the path; `spacing` (points) closes glyphs up round the outside of curves and opens them up round the
|
||||
inside. With none of them it queries the first selected type on a path:
|
||||
default) along the path, each character by its own font, size and vertical scale: `ascender` runs the top of the
|
||||
font's em box along it (its ideographic em box, else an em box centered on the cap height), `descender` runs the
|
||||
bottom of the font's bounding box along it, and `center` runs a line at half the height of the top of the font's H
|
||||
above the baseline along it (in vertical type, `ascender` and `descender` move the characters half an em with their
|
||||
horizontal scale, and `center` leaves them on the path); `spacing` (points) closes glyphs up round the outside of
|
||||
curves and opens them up round the inside. With none of them it queries the first selected type on a path:
|
||||
`{start, end, flip: false, effect, alignToPath, spacing}`. The Options dialog shows Effect, Flip, Align to Path and
|
||||
Spacing and leaves the brackets where they are. As in the reference app, the Selection and Direct Selection tools
|
||||
show selected type on a path's brackets: dragging the start or end bracket (`pointer_gesture`) sets where the type
|
||||
|
||||
@@ -209,7 +209,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
|
||||
| Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them |
|
||||
| Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged |
|
||||
| View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) |
|
||||
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path comes from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. |
|
||||
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. |
|
||||
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. |
|
||||
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. |
|
||||
| View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) |
|
||||
@@ -1060,6 +1060,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them.
|
||||
New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text.
|
||||
With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines.
|
||||
Type on a Path Options › Align to Path (option names as in [Create type on a path](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-type-on-a-path.html)) moves each character by its own font, size and vertical scale: Ascender runs the top of the font's em box along the path (the ideographic em box that its `BASE` table, its typographic or `hhea` box or its ideographs give, else an em box centered on the cap height), Descender runs the bottom of the font's bounding box along it, and Center runs a line at half the height of the top of the font's H above the baseline along it. In vertical type, Ascender and Descender move the characters half an em, and Center leaves them on the path. These distances are VectorCraft's own values (`tests::on_path_align_runs_each_glyph_by_its_own_metrics`, `tests::on_path_align_finds_the_em_box_of_each_kind_of_face`, `tests::on_path_align_in_vertical_type_moves_half_an_em`).
|
||||
The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it.
|
||||
The frames of threaded text share one writing direction: Type › Type Orientation (`type.orientation.vertical`, `type.orientation.horizontal`) in one frame sets every frame, threading frames gives the thread its first frame's direction, frames taken out of a thread keep theirs, and the story flows through every frame in the thread's direction.
|
||||
Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. Justification's Glyph Scaling leaves the glyphs that hang at their width, so they hang exactly their share, and Standard burasagari counts what hangs at a line's start as room before hanging a comma; `crates/text/src/tests/hang.rs` checks whole paragraphs with both composers, ragged, centred and justified, with Line-end Punctuation Half Width and bidirectional lines.
|
||||
|
||||
Reference in New Issue
Block a user