Legacy .ai: the type attributes warning covers kerning, margins, Align to Path and end brackets

The legacy .ai writer gives the type attributes warning when a text
object loses something it cannot hold. It did not give it for several
attributes that it does not write:

- Align to Path set to Ascender, Descender or Center.
- An end bracket before the end of an open path, or short of once
  around a closed path. A legacy text path holds the path and the start
  point only (AI7 File Format Specification, section 15).
- Manual kerning other than 0, which is written as kerning off, and
  optical kerning.
- Optical Margin Alignment, System Layout and fractional widths off.
- A second subpath in an area type frame or a type path. The writer
  writes the first subpath only.

The warning now names each of these.
This commit is contained in:
dotCooCoo
2026-11-09 00:34:44 +07:00
committed by bflatastic
parent fd91b29e20
commit 867c836fe1
3 changed files with 106 additions and 5 deletions
+2 -2
View File
@@ -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)";
+25 -3
View File
@@ -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
+79
View File
@@ -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]