diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index 412961802..de2c9b384 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -66,7 +66,7 @@ use vectorcraft_color::cms::Cms; use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageBlob, Justify, LAYER_COLORS, LayerColor, Node, NodeKind, - ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, + ParaDirection, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, TextObject, }; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Rect, Shape, SubPath, Vec2}; @@ -96,7 +96,7 @@ const HEX_LINE: usize = 60; pub const SUBLAYERS: &str = "sublayers are written as groups in their top-level layer (legacy .ai files have no sublayers)"; pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, ruby, inline graphics, gradient or pattern fills) is written as outlines"; pub const ROTATED_TYPE: &str = "type with rotated characters is written as outlines, which keep the rotation, and is no longer editable text (legacy .ai text can't rotate characters)"; -pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, OpenType features, Variable Font settings, area type options, type on a path effects, space after paragraphs, tabs, bulleted and numbered lists) are left out"; +pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, manual kerning, optical kerning, small caps, superscript and subscript, OpenType features, Variable Font settings, optical margin alignment, System Layout, fractional widths off, area type options, type on a path's effect, alignment and spacing, an end bracket short of the path's end, subpaths after the first in an area type frame or a type path, space after paragraphs, tabs, bulleted and numbered lists) are left out"; pub const HIDDEN: &str = "hidden objects are left out (hidden layers are kept, hidden)"; pub const HIDDEN_LAYER: &str = "hidden objects are moved to a hidden layer above their own (legacy .ai files can hide only layers)"; pub const HIDDEN_SHOWN: &str = "hidden objects are written visible (legacy .ai files can hide only layers)"; diff --git a/crates/eps/src/legacy_ai/encoding.rs b/crates/eps/src/legacy_ai/encoding.rs index ee8de7c16..c2346cbb9 100644 --- a/crates/eps/src/legacy_ai/encoding.rs +++ b/crates/eps/src/legacy_ai/encoding.rs @@ -1,7 +1,7 @@ use super::writer::Writer; use super::{ - Affine, AreaOptions, BezPath, CharPosition, CharStyle, Gradient, GradientKind, Paint, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject, - Write, latin, ps, stroke, + Affine, AreaOptions, BezPath, CharPosition, CharStyle, Gradient, GradientKind, Paint, ParaStyle, PathAlign, PathEffect, StrokeLayer, TextKind, + TextObject, Write, latin, ps, stroke, }; /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd`, `[`, the stops @@ -114,6 +114,8 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool { let run_lost = |s: &CharStyle| { s.underline || s.strikethrough + || s.kerning.is_some_and(|k| k != 0.0) + || s.optical_kerning || s.small_caps.is_some() || s.position != CharPosition::Normal || !s.features.is_empty() @@ -122,8 +124,28 @@ pub(super) fn lost_attributes(t: &TextObject) -> bool { let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty() || p.list.is_some(); t.runs.iter().any(|r| run_lost(&r.style)) || t.paragraph_styles().iter().any(para_lost) + || t.optical_margin + || t.system_layout + || !t.fractional_widths || (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default()) - || (matches!(t.kind, TextKind::OnPath { .. }) && (t.path_effect != PathEffect::default() || t.path_spacing != 0.0)) + || (matches!(t.kind, TextKind::OnPath { .. }) + && (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_bracket(t))) + || several_paths(&t.kind) +} + +/// Does type on a path `t` end before its path does (an open path's end, or once around a +/// closed path)? A legacy `.ai` text path holds where the type starts and no end (ยง15). +fn end_bracket(t: &TextObject) -> bool { + let (TextKind::OnPath { path, .. }, Some((start, end))) = (&t.kind, t.kind.path_span()) else { return false }; + let reach = if path.is_closed() { end - start } else { end }; + reach < 1.0 - 1e-9 +} + +/// Has the frame of area type, or the path of type on a path, `kind` more than one subpath with +/// anchors? A legacy `.ai` text object is written with the first one only. +fn several_paths(kind: &TextKind) -> bool { + let (TextKind::Area { frame: path } | TextKind::OnPath { path, .. }) = kind else { return false }; + path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()).count() > 1 } /// The font type in style `st` is set in: its PostScript name, whether it's TrueType, and its diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index 851228ea6..b34c127a7 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -1105,6 +1105,85 @@ fn variable_font_settings_are_warned_as_left_out() { assert!(String::from_utf8(out.bytes).unwrap().contains("(H\\351llo \\2005\\r) Tx\n"), "still written as text"); } +/// A legacy `.ai` text path holds the path and where the type starts on it, and no Align to Path +/// or end bracket: type on a path with either is written as text, with the warning that they are +/// left out. An end bracket at the end of an open path, or once around a closed one, is kept. A +/// path with a second subpath is written with its first one only, with the same warning. +#[test] +fn type_on_a_path_alignment_and_end_bracket_are_warned_as_left_out() { + use vectorcraft_doc::{PathAlign, TextKind, TextObject}; + let along = |closed: bool| { + let pts = [(150.0, 150.0).into(), (250.0, 150.0).into(), (250.0, 190.0).into()]; + PathData::new(vec![vectorcraft_geom::SubPath::polyline(&pts, closed)]) + }; + let warned = |edit: &dyn Fn(&mut TextObject)| { + let mut doc = Document::new(300.0, 200.0); + let layer = doc.layers[0].id; + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "Along", style(12.0, Paint::solid(Color::BLACK))); + t.kind = TextKind::OnPath { path: along(false), start: 0.25, end: None }; + t.xf = Affine::IDENTITY; + edit(&mut t); + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = write(&doc); + assert!(String::from_utf8(out.bytes).unwrap().contains("(Along) Tx\n"), "still written as text"); + out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES) + }; + assert!(!warned(&|_| {})); + for align in [PathAlign::Ascender, PathAlign::Descender, PathAlign::Center] { + assert!(warned(&|t| t.path_align = align), "{align:?}"); + } + assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.25, end: Some(0.6) })); + assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(true), start: 0.25, end: Some(0.9) })); + assert!(!warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.25, end: Some(1.0) })); + assert!(!warned(&|t| t.kind = TextKind::OnPath { path: along(true), start: 0.1, end: Some(0.1) })); + assert!(!warned(&|t| t.kind = TextKind::OnPath { path: along(true), start: 0.7, end: None })); + // A closed path's span that wraps past the path's start and stops short of a full turn. + assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(true), start: 0.7, end: Some(0.5) })); + // An open path's end bracket before its start bracket. + assert!(warned(&|t| t.kind = TextKind::OnPath { path: along(false), start: 0.5, end: Some(0.3) })); + let two = |first_closed: bool| { + let mut path = along(first_closed); + let pts = [(150.0, 100.0).into(), (250.0, 100.0).into()]; + path.subpaths.push(vectorcraft_geom::SubPath::polyline(&pts, false)); + path + }; + assert!(warned(&|t| t.kind = TextKind::OnPath { path: two(true), start: 0.25, end: None })); + assert!(warned(&|t| t.kind = TextKind::OnPath { path: two(false), start: 0.25, end: None })); +} + +/// Kerning other than Auto or 0, optical kerning, Optical Margin Alignment, System Layout, +/// fractional widths off and an area type frame with a second subpath are not held by a legacy +/// `.ai` text object: type with any of them is written as text, with the warning that they are +/// left out. +#[test] +fn kerning_margins_widths_and_extra_frames_are_warned_as_left_out() { + use vectorcraft_doc::{TextKind, TextObject}; + let warned = |edit: &dyn Fn(&mut TextObject)| { + let mut doc = typed(); + let id = texts(&doc)[0].0.id; + if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) { + edit(t); + } + let out = write(&doc); + assert!(String::from_utf8(out.bytes).unwrap().contains(") Tx\n"), "still written as text"); + out.warnings.iter().any(|w| w == TYPE_ATTRIBUTES) + }; + assert!(!warned(&|_| {})); + assert!(!warned(&|t| t.runs[0].style.kerning = Some(0.0))); + assert!(warned(&|t| t.runs[0].style.kerning = Some(50.0))); + assert!(warned(&|t| t.runs[0].style.optical_kerning = true)); + assert!(warned(&|t| t.optical_margin = true)); + assert!(warned(&|t| t.system_layout = true)); + assert!(warned(&|t| t.fractional_widths = false)); + let square = |x: f64| { + let pts = [(x, 10.0).into(), (x + 80.0, 10.0).into(), (x + 80.0, 90.0).into(), (x, 90.0).into()]; + vectorcraft_geom::SubPath::polyline(&pts, true) + }; + assert!(!warned(&|t| t.kind = TextKind::Area { frame: PathData::new(vec![square(10.0)]) })); + assert!(warned(&|t| t.kind = TextKind::Area { frame: PathData::new(vec![square(10.0), square(120.0)]) })); +} + /// Type whose font size measures the cap height is written at the em size it is drawn at, with /// no warning: 14 pt measuring a 0.7 em cap height reads back as 20 pt type. #[test]