Part of #868: legacy .ai type as text objects and hidden objects kept; legacy type read

Type is written as version 7 text objects (§15): point type, area type and type on a path, with
fonts by PostScript name re-encoded to Windows-1252, and each run's paint, size, scaling,
tracking, leading, alignment and indents; type 8-bit text can't hold (or all, with outlineText)
is outlines. The format hides only layers, so hidden objects go to a hidden layer above their
own (hiddenObjects "layer", the default), are left out ("omit") or show ("visible"). The .ai
reader reads legacy type (To … TO, TE, %AI3_BeginEncoding) and ignores Ar; the import fuzzer
gets legacy type tokens.
This commit is contained in:
Dan Green
2026-10-16 01:36:24 +07:00
committed by bflatastic
parent 32df15a554
commit 6ce1a622be
12 changed files with 1312 additions and 37 deletions
+38 -6
View File
@@ -16,6 +16,12 @@
//! channel, §8.1). With `flatten` (the default) each becomes an opaque image of itself and the art
//! under it, rendered from the document as it is, clipped to the area where it changes what shows;
//! the art under it stays as it was. With `omit` they are left out.
//!
//! The format can't hide objects, only layers (`hiddenObjects`): with `layer` (the default) each
//! layer's hidden objects go to a hidden layer of their own just above it, with `omit` they are
//! left out, with `visible` they show where they are. Type is written as text the format's reader
//! lays out again, in the fonts it names; `outlineText` writes its outlines instead (as type that
//! 8-bit text can't hold always is).
use std::sync::Arc;
@@ -45,6 +51,27 @@ enum SeeThrough {
Omit,
}
/// Where a legacy `.ai`'s hidden objects go (`hiddenObjects`): the format hides only layers.
fn hidden_objects(cmd: &str, p: &Value) -> Result<vectorcraft_eps::HiddenObjects> {
use vectorcraft_eps::HiddenObjects;
match p.get("hiddenObjects") {
None | Some(Value::Null) => Ok(HiddenObjects::Layer),
Some(Value::String(v)) if v.trim().is_empty() || v.trim().eq_ignore_ascii_case("layer") => Ok(HiddenObjects::Layer),
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("omit") => Ok(HiddenObjects::Omit),
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("visible") => Ok(HiddenObjects::Visible),
Some(v) => Err(bad(cmd, format!("hiddenObjects {v}: \"layer\" (the default), \"omit\" or \"visible\""))),
}
}
/// Type as outlines (`outlineText`), not text objects.
fn outline_text(cmd: &str, p: &Value) -> Result<bool> {
match p.get("outlineText") {
None | Some(Value::Null) => Ok(false),
Some(Value::Bool(b)) => Ok(*b),
Some(v) => Err(bad(cmd, format!("outlineText {v}: true or false"))),
}
}
fn see_through(cmd: &str, p: &Value) -> Result<SeeThrough> {
match p.get("transparentImages") {
None | Some(Value::Null) => Ok(SeeThrough::Flatten),
@@ -55,7 +82,7 @@ fn see_through(cmd: &str, p: &Value) -> Result<SeeThrough> {
}
/// What the format loses against a VectorCraft document, reported with every save.
const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable";
const FIDELITY: &str = "a legacy .ai (version 7) keeps layers, groups, compound paths, clipping groups, type, colours, gradients and images, not the rest: save as VectorCraft to keep everything editable";
const FLATTENED: &str =
"transparent images (see-through pixels, opacity or a blending mode) are flattened with the art under them into opaque images";
const OMITTED: &str = "transparent images (see-through pixels, opacity or a blending mode) are left out";
@@ -69,6 +96,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
return Err(bad(cmd, "the document has no artboard"));
}
let mode = see_through(cmd, p)?;
let (hidden, outline_text) = (hidden_objects(cmd, p)?, outline_text(cmd, p)?);
let picked: ArtboardPick = super::encode::options(f, p)?;
let boards = picked.resolve(n).map_err(|e| bad(cmd, e))?.unwrap_or_else(|| (0..n).collect());
let mut enc = Encoded { warnings: vec![FIDELITY.to_string()], ..Encoded::default() };
@@ -81,7 +109,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
let (full, placed) = crate::cmd::place::document::full_documents(&one);
let mut d = full.into_owned();
expand_appearances(&mut d);
let images = see_through_images(&d);
let images = see_through_images(&d, mode);
if !images.is_empty() {
note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED });
if mode == SeeThrough::Flatten {
@@ -92,7 +120,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
}
}
}
let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created };
let o = vectorcraft_eps::LegacyAiOptions { title: doc.title.clone(), created, hidden, outline_text };
let out = vectorcraft_eps::export_legacy_ai(&d, &o).map_err(|e| bad(cmd, e))?;
for w in placed.iter().chain(&out.warnings) {
note(&mut enc.warnings, w);
@@ -113,8 +141,10 @@ fn expand_appearances(d: &mut Document) {
}
/// The images of `d` a legacy `.ai` can't hold, in paint order with their places in it (as
/// [`Document::walk`] counts): visible ones with see-through pixels, opacity or a blending mode.
fn see_through_images(d: &Document) -> Vec<(usize, NodeId)> {
/// [`Document::walk`] counts): those with see-through pixels, opacity or a blending mode. Only
/// visible ones flatten (a hidden one, which the hidden objects option may write, is written over
/// white); all are left out with `omit`.
fn see_through_images(d: &Document, mode: SeeThrough) -> Vec<(usize, NodeId)> {
let keys = super::super::flatten::see_through_images(d);
let mut out = vec![];
let mut at = 0;
@@ -126,7 +156,9 @@ fn see_through_images(d: &Document) -> Vec<(usize, NodeId)> {
}
at += 1;
});
out.retain(|(_, id)| d.is_visible(*id));
if mode == SeeThrough::Flatten {
out.retain(|(_, id)| d.is_visible(*id));
}
out
}
+13 -1
View File
@@ -103,7 +103,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[
name: "aiVersion",
ty: "string",
default: "\"pdf\"",
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque, type as outlines; the other .ai options don't apply)",
description: "pdf (a PDF-compatible .ai carrying the VectorCraft document) or 7 (the legacy, PostScript-form .ai of version 7, which keeps layers, groups, compound paths and clipping groups in apps that read it; one file per artboard, transparent objects opaque; the other .ai options don't apply)",
},
FormatOption {
name: "transparentImages",
@@ -111,6 +111,18 @@ pub const AI_OPTIONS: &[FormatOption] = &[
default: "\"flatten\"",
description: "with aiVersion 7, the images version 7 can't hold (see-through pixels, opacity or a blending mode): flatten (the default: each becomes an opaque image of itself and the art under it, clipped to where it changes what shows; that art stays as it is) or omit (left out)",
},
FormatOption {
name: "hiddenObjects",
ty: "string",
default: "\"layer\"",
description: "with aiVersion 7, hidden objects, which version 7 can't hide (it hides only layers): layer (the default: each layer's hidden objects in a hidden layer of their own just above it, out of their groups), omit (left out) or visible (shown where they are)",
},
FormatOption {
name: "outlineText",
ty: "boolean",
default: "false",
description: "with aiVersion 7, type as its outlines instead of text (which apps reading the file set in the fonts it names: they need them installed); type 8-bit text can't hold (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is always outlines",
},
];
#[derive(Default, Deserialize)]
+60 -2
View File
@@ -93,7 +93,7 @@ fn the_default_stays_pdf_compatible_and_other_versions_are_refused() {
}
#[test]
fn transparency_type_and_appearances_are_written_as_plain_art() {
fn transparency_and_appearances_are_written_as_plain_art_and_type_as_text() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
@@ -105,7 +105,7 @@ fn transparency_type_and_appearances_are_written_as_plain_art() {
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7"})).unwrap();
let w = warnings(&r);
assert!(w.iter().any(|w| w.contains("written opaque")), "{w:?}");
assert!(w.iter().any(|w| w.contains("outlines")), "{w:?}");
assert!(!w.iter().any(|w| w.contains("type") && w.contains("outlines")), "{w:?}");
let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
let text = String::from_utf8(bytes.clone()).unwrap();
// No transparency operators: version 7 has none.
@@ -116,6 +116,64 @@ fn transparency_type_and_appearances_are_written_as_plain_art() {
let mut opaque = true;
d.walk(|n| opaque &= n.opacity == 1.0);
assert!(opaque);
assert_eq!(texts(d), ["Hi"]);
}
/// The plain text of every text object of `d`.
fn texts(d: &vectorcraft_doc::Document) -> Vec<String> {
let mut out = vec![];
d.walk(|n| {
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
out.push(t.plain_text());
}
});
out
}
#[test]
fn type_can_be_written_as_outlines() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("text.create", &json!({"x": 20, "y": 50, "text": "Café", "size": 24})).unwrap();
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "outlineText": true})).unwrap();
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = &s.doc().unwrap().doc;
assert!(texts(d).is_empty());
let mut paths = 0;
d.walk(|n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Compound { .. } | vectorcraft_doc::NodeKind::Path { .. })));
assert!(paths > 0);
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "outlineText": "yes"})).is_err());
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(v["options"]["outlineText"]["value"], false, "{v}");
}
#[test]
fn hidden_objects_go_to_a_hidden_layer_or_are_left_out_or_shown() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.rectangle", &json!({"x": 60, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("object.hide", &json!({})).unwrap();
let saved = |s: &mut Session, hidden: Value| {
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "hiddenObjects": hidden})).unwrap();
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
let d = s.doc().unwrap().doc.clone();
s.execute("file.close", &json!({})).unwrap();
(d, warnings(&r))
};
let (d, w) = saved(&mut s, Value::Null);
assert_eq!(layer_names(&d), ["Layer 1", "Layer 1 (hidden)"], "{w:?}");
assert!(d.layers[0].visible && !d.layers[1].visible);
assert!(w.iter().any(|w| w.contains("hidden layer")), "{w:?}");
let (d, _) = saved(&mut s, json!("omit"));
assert_eq!(layer_names(&d), ["Layer 1"]);
assert_eq!(d.layers[0].children().map(Vec::len), Some(1));
let (d, _) = saved(&mut s, json!("visible"));
assert_eq!(layer_names(&d), ["Layer 1"]);
assert_eq!(d.layers[0].children().map(|c| c.iter().filter(|n| n.visible).count()), Some(2));
assert!(s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "hiddenObjects": "behind"})).is_err());
let v = s.execute("file.formatOptions", &json!({"format": "ai"})).unwrap();
assert_eq!(v["options"]["hiddenObjects"]["value"], "layer", "{v}");
}
#[test]
+31
View File
@@ -2152,6 +2152,37 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
"\n%AI3_BeginPattern: (Dots)\n(Dots) 0 0 6 6\n",
"\n%AI3_EndPattern\n",
"To",
// Legacy type (version 7 §15): text objects, their paths, attributes and strings.
"0 To",
"1 To",
"2 To",
"1e308 To",
"TO",
"1 0 0 1 10 10 0 Tp",
"0 0 0 0 1e308 -1e308 1e308 Tp",
"-1 0 0 -1 5 5 99.5 Tp",
"TP",
"/_Helvetica 12 9 3 Tf",
"/_ 1e308 Tf",
"-5 Tf",
"(H\\351llo\\r) Tx",
"(\\r) TX",
"() Tj",
"(x) Tx",
"7 Tr",
"-1e308 Ts",
"1e308 0 Tz",
"1e308 Tt",
"0 TA",
"1e308 -1e308 Tl",
"9 Ta",
"1e308 -1e308 1e308 Ti",
"0 1 Tk",
"[39/quotesingle 128/Euro/eacute/nosuch 300/x TE",
"[1e308/a -1/b TE",
"\n%AI3_BeginEncoding: _Helvetica Helvetica\n[/_Helvetica/Helvetica 0 0 1 TZ\n%AI3_EndEncoding AdobeType\n",
"\n%AI3_BeginEncoding:\n",
"800 Ar",
"frobnicate",
"\n%AI5_BeginLayer\n",
"\n%AI5_EndLayer--\n",
+310
View File
@@ -0,0 +1,310 @@
//! Type in the legacy format (version 7 specification §15): text objects `type To` … `TO`, their
//! text path (`a b c d tx ty startPt Tp`, an area's frame or the path type runs along, `TP`), the
//! modal character and paragraph attributes (`Tr Tf Ts Tz Tt TA Tl Ta Ti`), the paint style, and
//! the characters (`Tx`, `Tj`, overflow `TX`; `\r` ends a paragraph). Final-form operators (the
//! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the
//! file's `TE` encoding, else as Windows-1252; a re-encoded font `/_name` is the font its
//! `%AI3_BeginEncoding` names (else `name`).
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
use vectorcraft_geom::PathData;
use super::super::obj::V;
use super::{FLIP, GState, Reader};
use crate::latin;
/// Most characters of type read in one file.
const MAX_CHARS: usize = 1 << 22;
/// Largest type size and indent read (points).
const MAX_SIZE: f64 = 1e4;
/// A text object being read.
#[derive(Debug, Default)]
pub(super) struct LegacyText {
/// `To`'s type: 0 point type, 1 area type, 2 type on a path.
kind: i64,
/// `Tp`'s matrix: text space (y up) → art space.
matrix: Option<Affine>,
/// Where type on a path starts: a segment's index plus how far along it.
start: f64,
/// Between `Tp` and `TP`: the path read is the text path.
in_path: bool,
/// The frame or path, in art space.
path: Option<BezPath>,
runs: Vec<TextRun>,
/// Each paragraph's attributes, as they were at its first character.
paras: Vec<ParaStyle>,
/// The current paragraph's attributes are in `paras`.
para_taken: bool,
/// An overflow paragraph break (`(\r) TX`) not yet followed by type: the story's end.
pending_break: bool,
/// The state when it began (lock, visibility), and whether it began on a `%_` line.
gs: GState,
hidden: bool,
}
/// The modal type attributes of the legacy format, which last from one text object to the next.
#[derive(Debug)]
pub(super) struct TypeState {
style: CharStyle,
para: ParaStyle,
/// `Tr`.
render: i64,
/// The encoding strings are in, by code (`TE`); None where it says nothing.
encoding: Vec<Option<char>>,
/// Re-encoded fonts' original names (`%AI3_BeginEncoding: _new old`).
fonts: Vec<(String, String)>,
chars: usize,
}
impl Default for TypeState {
fn default() -> Self {
Self { style: CharStyle::default(), para: ParaStyle::default(), render: 0, encoding: vec![None; 256], fonts: vec![], chars: 0 }
}
}
impl TypeState {
/// `%AI3_BeginEncoding: _new old` (`line` the comment).
pub(super) fn encoding_comment(&mut self, line: &str) {
let Some((_, names)) = line.split_once(':') else { return };
let mut words = names.split_whitespace();
if let (Some(new), Some(old)) = (words.next(), words.next())
&& self.fonts.len() < 4096
{
self.fonts.push((new.trim_start_matches('/').to_string(), old.trim_start_matches('/').to_string()));
}
}
}
fn num(vals: &[V], i: usize) -> Option<f64> {
vals.iter().filter_map(V::num).nth(i).filter(|v| v.is_finite())
}
impl Reader<'_> {
/// `[encodingPairs TE`: codes, each followed by the glyph names from it on (§3.3.1).
pub(super) fn te(&mut self, vals: &[V]) {
let mut code: Option<usize> = None;
for v in vals {
match v {
V::Num(n) if n.is_finite() && (0.0..256.0).contains(n) => code = Some(*n as usize),
V::Name(name) => {
if let Some(c) = code {
if let (Some(slot), Ok(b)) = (self.type_state.encoding.get_mut(c), u8::try_from(c)) {
*slot = latin::glyph(b, name);
}
code = Some(c + 1);
}
}
_ => {}
}
}
}
/// Is a text object's path being read (so the path painted is its frame or path)?
pub(super) fn in_text_path(&self) -> bool {
self.text.as_ref().is_some_and(|t| t.in_path)
}
/// `type To`: a text object begins.
pub(super) fn begin_text(&mut self, vals: &[V], hidden: bool) {
self.end_path_quietly();
let kind = num(vals, 0).map_or(0, |k| k as i64);
self.text = Some(LegacyText { kind, gs: self.gs.clone(), hidden, ..LegacyText::default() });
}
/// `a b c d tx ty startPt Tp`: the text path begins.
pub(super) fn begin_text_path(&mut self, vals: &[V]) {
self.end_path_quietly();
let n: Vec<f64> = vals.iter().filter_map(V::num).collect();
let Some(t) = self.text.as_mut() else { return };
if let Some(m) = n.get(..6).and_then(|m| <[f64; 6]>::try_from(m).ok()) {
let m = Affine::new(m);
let det = m.determinant();
if m.as_coeffs().iter().all(|v| v.is_finite()) && det.is_finite() && det.abs() > 1e-12 {
t.matrix = Some(m);
}
}
t.start = n.get(6).copied().filter(|s| s.is_finite() && *s >= 0.0).unwrap_or(0.0);
t.in_path = true;
}
/// The path painted (or `TP`, ending the text path): the text object's frame or path.
pub(super) fn text_path(&mut self) {
let path = std::mem::take(&mut self.path);
self.end_path_quietly();
if let Some(t) = self.text.as_mut()
&& t.path.is_none()
&& !path.elements().is_empty()
{
t.path = Some(path);
}
}
/// `TP`.
pub(super) fn end_text_path(&mut self) {
self.text_path();
if let Some(t) = self.text.as_mut() {
t.in_path = false;
}
}
/// A character or paragraph attribute.
pub(super) fn text_attr(&mut self, w: &str, vals: &[V]) {
let st = &mut self.type_state;
let n0 = num(vals, 0);
match w {
"Tr" => st.render = n0.map_or(0, |v| v as i64),
"Tf" => {
if let Some(name) = vals.iter().find_map(|v| if let V::Name(n) = v { Some(n.clone()) } else { None }) {
let name = st
.fonts
.iter()
.find(|(new, _)| *new == name)
.map_or_else(|| name.trim_start_matches('_').to_string(), |(_, old)| old.clone());
let (family, style) = super::super::super::text::family_style(&name);
st.style.font_family = family;
st.style.font_style = style;
st.style.font_version = None;
}
if let Some(size) = num(vals, 0).filter(|s| *s > 0.0) {
st.style.size = size.min(MAX_SIZE);
}
}
"Ts" => st.style.baseline_shift = n0.unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE),
"Tz" => {
let scale = |v: f64| v.clamp(1.0, 10_000.0);
st.style.h_scale = n0.map_or(100.0, scale);
st.style.v_scale = num(vals, 1).map_or(100.0, scale);
}
"Tt" => st.style.tracking = n0.unwrap_or(0.0).clamp(-10_000.0, 10_000.0),
"TA" => st.style.kerning = if n0 == Some(0.0) { Some(0.0) } else { None },
"Tl" => {
if let Some(l) = n0 {
let l = l.clamp(0.0, MAX_SIZE);
// The automatic leading, 120% of the size, stays automatic.
st.style.leading = if (l - st.style.size * 1.2).abs() < 1e-3 { None } else { Some(l) };
}
st.para.space_before = num(vals, 1).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE);
}
"Ta" => {
st.para.justify = match n0.map(|v| v as i64) {
Some(1) => Justify::Center,
Some(2) => Justify::Right,
Some(3) => Justify::JustifyLeft,
Some(4) => Justify::JustifyAll,
_ => Justify::Left,
}
}
"Ti" => {
let indent = |i: usize| num(vals, i).unwrap_or(0.0).clamp(-MAX_SIZE, MAX_SIZE);
(st.para.first_line_indent, st.para.left_indent, st.para.right_indent) = (indent(0), indent(1), indent(2));
}
_ => {}
}
}
/// `(…) Tx` (`Tj`, overflow `TX`): characters in the current style and paint.
pub(super) fn text_body(&mut self, w: &str, vals: &[V]) {
let Some(bytes) = vals.iter().rev().find_map(|v| if let V::Str(s) = v { Some(s.clone()) } else { None }) else { return };
let st = &mut self.type_state;
let Some(t) = self.text.as_mut() else { return };
let decoded: String = bytes
.iter()
.map(|&b| if b == b'\r' { '\n' } else { st.encoding.get(usize::from(b)).copied().flatten().unwrap_or_else(|| latin::decode(b)) })
.collect();
if decoded.is_empty() {
return;
}
// Overflow text ending the story with a paragraph break (`(\r) TX`) adds no paragraph.
if w == "TX" && decoded == "\n" {
t.pending_break = true;
return;
}
if st.chars >= MAX_CHARS {
return;
}
st.chars += decoded.chars().count();
let gs = &self.gs;
let mut style = st.style.clone();
let (fill, stroke) = match st.render {
0 | 4 => (true, false),
1 | 5 => (false, true),
2 | 6 => (true, true),
_ => (false, false),
};
style.fill = if fill { gs.fill.clone() } else { Paint::None };
style.stroke = if stroke { gs.stroke.clone() } else { Paint::None };
style.stroke_width = if stroke { gs.width } else { 0.0 };
(style.stroke_cap, style.stroke_join, style.stroke_miter_limit, style.stroke_dash) = (gs.cap, gs.join, gs.miter, gs.dash.clone());
(style.overprint_fill, style.overprint_stroke) = (gs.overprint_fill, gs.overprint_stroke);
let mut text = String::new();
if std::mem::take(&mut t.pending_break) {
text.push('\n');
t.para_taken = false;
}
for c in decoded.chars() {
if c != '\n' && !t.para_taken {
t.paras.push(st.para.clone());
t.para_taken = true;
}
text.push(c);
if c == '\n' {
if !t.para_taken {
t.paras.push(st.para.clone());
}
t.para_taken = false;
}
}
match t.runs.last_mut() {
Some(last) if last.style == style => last.text.push_str(&text),
_ => t.runs.push(TextRun::new(text, style)),
}
}
/// `TO`: the text object ends.
pub(super) fn end_text(&mut self, hidden: bool) -> Result<(), String> {
let Some(t) = self.text.take() else { return Ok(()) };
if t.runs.is_empty() {
return Ok(());
}
let m = t.matrix.unwrap_or(Affine::IDENTITY);
let to_doc = self.space();
// Art space → text space (y down).
let to_text = FLIP * m.inverse();
let kind = match (t.kind, &t.path) {
(1, Some(p)) => TextKind::Area { frame: PathData::from_bezpath(&(to_text * p.clone())) },
(2, Some(p)) => TextKind::OnPath { path: PathData::from_bezpath(&(to_text * p.clone())), start: start_fraction(p, t.start), end: None },
_ => TextKind::Point,
};
let mut obj = TextObject::point(kurbo::Point::ZERO, "", CharStyle::default());
obj.kind = kind;
obj.xf = to_doc * m * FLIP;
obj.runs = t.runs;
let mut paras = t.paras;
if paras.is_empty() {
paras.push(self.type_state.para.clone());
}
obj.set_paragraph_styles(paras);
let node = self.new_node(NodeKind::Text(Box::new(obj)), &t.gs);
self.add(node, t.hidden || hidden)
}
}
/// Where type on `path` starts (`index.fraction`: a segment and how far along it, §15.5.2), as a
/// fraction of its length.
fn start_fraction(path: &BezPath, start: f64) -> f64 {
const ACCURACY: f64 = 1e-3;
let segments: Vec<kurbo::PathSeg> = path.segments().collect();
let lengths: Vec<f64> = segments.iter().map(|s| s.arclen(ACCURACY)).collect();
let total: f64 = lengths.iter().sum();
if !(total.is_finite() && total > 0.0 && start.is_finite()) {
return 0.0;
}
let index = start.floor().max(0.0) as usize;
let Some(seg) = segments.get(index) else { return 1.0 };
let before: f64 = lengths.iter().take(index).sum();
let along = seg.subsegment(0.0..(start - start.floor()).clamp(0.0, 1.0)).arclen(ACCURACY);
((before + along) / total).clamp(0.0, 1.0)
}
+31 -4
View File
@@ -30,9 +30,12 @@
//! `(name) x y scaleX scaleY angle reflect axis shear shearAxis [matrix] p` (`P`), the matrix
//! taking the pattern's space to art space (the other operands are what it is made of).
//!
//! - Type in the legacy format (version 7 specification §15): `type To` … `TO`, read by
//! [`legacy_text`].
//!
//! Sections the reader doesn't need (brushes, symbols, styles, swatches, filters) are skipped;
//! type, symbols, fills with a pattern it can't read, placed files without a preview, legacy type
//! and operators it doesn't know are errors.
//! type, symbols, fills with a pattern it can't read, placed files without a preview and operators
//! it doesn't know are errors.
use std::collections::{BTreeSet, HashMap, HashSet};
use std::rc::Rc;
@@ -54,6 +57,9 @@ use super::obj::{self, Obj, V};
use super::paint::{self, GradientDef, Instance, Named};
use crate::ps;
#[path = "legacy_text.rs"]
mod legacy_text;
/// Deepest nesting of layers, groups and dictionaries.
const MAX_DEPTH: usize = 256;
/// Most operands on the stack.
@@ -389,6 +395,9 @@ struct Reader<'a> {
pattern: Option<PatternHead>,
/// The tile's top left (y down) of each pattern read, by name.
pattern_tiles: HashMap<String, Point>,
/// The legacy text object being read (`To` … `TO`), and the modal type attributes.
text: Option<legacy_text::LegacyText>,
type_state: legacy_text::TypeState,
}
/// Operators that change nothing the reader keeps.
@@ -411,7 +420,8 @@ const IGNORED: &[&str] = &[
"Np",
// Flatness (`i`): how finely curves print.
"i",
"TE",
// Path resolution (`Ar`, §12): how finely curves print.
"Ar",
"TZ",
"Xs",
"Xt",
@@ -517,6 +527,8 @@ impl<'a> Reader<'a> {
template_center: None,
pattern: None,
pattern_tiles: HashMap::new(),
text: None,
type_state: legacy_text::TypeState::default(),
}
}
@@ -595,6 +607,9 @@ impl<'a> Reader<'a> {
self.styles = super::style::art_styles(section);
return Ok(());
}
if m == "BeginEncoding" {
self.type_state.encoding_comment(line);
}
if let Some((_, end)) = SKIPPED.iter().find(|(b, _)| *b == m) {
return self.skip_to(end);
}
@@ -1003,6 +1018,8 @@ impl<'a> Reader<'a> {
// Ends a path that stays open.
"H" => {}
"W" => self.clip_next = true,
// A text object's frame or path.
"N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" if self.in_text_path() => self.text_path(),
"N" | "n" | "F" | "f" | "S" | "s" | "B" | "b" => self.paint(w, hidden, false)?,
"*" => {
let op = vals.iter().rev().find_map(V::text).unwrap_or_else(|| "N".into());
@@ -1110,7 +1127,17 @@ impl<'a> Reader<'a> {
}
"~" if self.place.is_some() => self.end_place(hidden)?,
// Type in the legacy format.
"To" | "TO" | "Tp" | "TP" | "Tx" | "TX" | "Tj" | "Tk" => self.unreadable("type in the legacy format"),
"To" => self.begin_text(&vals, hidden),
"Tp" => self.begin_text_path(&vals),
"TP" => self.end_text_path(),
"TO" => self.end_text(hidden)?,
"Tx" | "Tj" | "TX" => self.text_body(w, &vals),
"Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" => self.text_attr(w, &vals),
// Attributes the reader doesn't keep (kerning pairs, spacing, hanging punctuation,
// Far-Eastern type) and final-form positions (§15.3).
"Tk" | "TK" | "TC" | "TW" | "Tq" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TV" | "Tv" | "Ty" | "TY" | "TG" | "Tg"
| "Tu" | "Xu" => {}
"TE" => self.te(&vals),
// An image whose samples follow in comment lines (read at `%AI5_EndRaster`).
"XI" if self.raster.is_some() => {
if let Some(r) = self.raster.as_mut() {
+255
View File
@@ -0,0 +1,255 @@
//! The 8-bit encoding of type in legacy `.ai` files: Windows-1252, its glyphs named as the
//! WinAnsiEncoding of the PDF specification names them (ISO 32000-1, Annex D.2). The writer
//! installs it with `TE` (version 7 specification §3.3.1) and encodes strings in it; the reader
//! decodes strings by the `TE` a file has (its glyph names, which may be another platform's).
/// The glyph names of codes 128–255 (`.notdef` where Windows-1252 has no character).
const HIGH: [&str; 128] = [
"Euro",
".notdef",
"quotesinglbase",
"florin",
"quotedblbase",
"ellipsis",
"dagger",
"daggerdbl",
"circumflex",
"perthousand",
"Scaron",
"guilsinglleft",
"OE",
".notdef",
"Zcaron",
".notdef",
".notdef",
"quoteleft",
"quoteright",
"quotedblleft",
"quotedblright",
"bullet",
"endash",
"emdash",
"tilde",
"trademark",
"scaron",
"guilsinglright",
"oe",
".notdef",
"zcaron",
"Ydieresis",
"space",
"exclamdown",
"cent",
"sterling",
"currency",
"yen",
"brokenbar",
"section",
"dieresis",
"copyright",
"ordfeminine",
"guillemotleft",
"logicalnot",
"hyphen",
"registered",
"macron",
"degree",
"plusminus",
"twosuperior",
"threesuperior",
"acute",
"mu",
"paragraph",
"periodcentered",
"cedilla",
"onesuperior",
"ordmasculine",
"guillemotright",
"onequarter",
"onehalf",
"threequarters",
"questiondown",
"Agrave",
"Aacute",
"Acircumflex",
"Atilde",
"Adieresis",
"Aring",
"AE",
"Ccedilla",
"Egrave",
"Eacute",
"Ecircumflex",
"Edieresis",
"Igrave",
"Iacute",
"Icircumflex",
"Idieresis",
"Eth",
"Ntilde",
"Ograve",
"Oacute",
"Ocircumflex",
"Otilde",
"Odieresis",
"multiply",
"Oslash",
"Ugrave",
"Uacute",
"Ucircumflex",
"Udieresis",
"Yacute",
"Thorn",
"germandbls",
"agrave",
"aacute",
"acircumflex",
"atilde",
"adieresis",
"aring",
"ae",
"ccedilla",
"egrave",
"eacute",
"ecircumflex",
"edieresis",
"igrave",
"iacute",
"icircumflex",
"idieresis",
"eth",
"ntilde",
"ograve",
"oacute",
"ocircumflex",
"otilde",
"odieresis",
"divide",
"oslash",
"ugrave",
"uacute",
"ucircumflex",
"udieresis",
"yacute",
"thorn",
"ydieresis",
];
/// Codes 128–159 of Windows-1252 (the others are Latin-1's); its five gaps are C1 controls.
const C1: [char; 32] = [
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8D}', 'Ž', '\u{8F}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—', '˜',
'™', 'š', '›', 'œ', '\u{9D}', 'ž', 'Ÿ',
];
/// Glyph names other platforms' encodings use that Windows-1252 has no code for (Mac Roman's).
const OTHER_NAMES: &[(&str, char)] = &[
("notequal", '≠'),
("infinity", '∞'),
("lessequal", '≤'),
("greaterequal", '≥'),
("partialdiff", '∂'),
("summation", '∑'),
("product", '∏'),
("pi", 'π'),
("integral", '∫'),
("Omega", 'Ω'),
("radical", '√'),
("approxequal", '≈'),
("Delta", '∆'),
("lozenge", '◊'),
("fraction", '⁄'),
("fi", 'fi'),
("fl", 'fl'),
("dotlessi", 'ı'),
("breve", '˘'),
("dotaccent", '˙'),
("ring", '˚'),
("hungarumlaut", '˝'),
("ogonek", '˛'),
("caron", 'ˇ'),
("nbspace", '\u{A0}'),
("quotesingle", '\''),
("grave", '`'),
];
/// Character `b` of Windows-1252.
pub(crate) fn decode(b: u8) -> char {
match b {
0x80..=0x9F => C1.get(usize::from(b - 0x80)).copied().unwrap_or('?'),
_ => char::from(b),
}
}
/// The code of `c` in Windows-1252: the printable characters, tab and the line breaks; None for
/// the others (and the C1 controls).
pub(crate) fn encode(c: char) -> Option<u8> {
match c {
'\t' | '\n' | '\r' | ' '..='~' | '\u{A0}'..='\u{FF}' => u8::try_from(u32::from(c)).ok(),
_ => C1.iter().position(|&x| x == c && !x.is_control()).and_then(|i| u8::try_from(0x80 + i).ok()),
}
}
/// The `TE` that installs the encoding: its codes 39 and 96 as in ASCII (PostScript's standard
/// encoding has quotes there) and 128–255 by name.
pub(crate) fn te() -> String {
let mut s = String::from("[39/quotesingle 96/grave 128");
for (i, name) in HIGH.iter().enumerate() {
s.push('/');
s.push_str(name);
if i % 8 == 7 && i + 1 < HIGH.len() {
s.push('\n');
}
}
s.push_str("\nTE\n");
s
}
/// The character glyph `name` stands for at code `code` of an encoding: that code's Windows-1252
/// character when the name is the one this encoding gives it; else by the name.
pub(crate) fn glyph(code: u8, name: &str) -> Option<char> {
if code >= 0x80 && HIGH.get(usize::from(code - 0x80)) == Some(&name) && name != ".notdef" {
return Some(decode(code));
}
if let Some(i) = HIGH.iter().position(|n| *n == name && *n != ".notdef" && *n != "space") {
return u8::try_from(0x80 + i).ok().map(decode);
}
if let Some((_, c)) = OTHER_NAMES.iter().find(|(n, _)| *n == name) {
return Some(*c);
}
match name {
"space" => Some(' '),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_encodable_character_decodes_as_itself() {
for b in 0..=255u8 {
let c = decode(b);
if let Some(e) = encode(c) {
assert_eq!(e, b, "{c:?}");
}
}
assert_eq!(encode('é'), Some(0xE9));
assert_eq!(encode('€'), Some(0x80));
assert_eq!(encode('—'), Some(0x97));
assert_eq!(encode('Ω'), None);
assert_eq!(encode('\u{81}'), None);
}
#[test]
fn glyph_names_decode_by_code_and_by_name() {
assert_eq!(glyph(0xE9, "eacute"), Some('é'));
assert_eq!(glyph(0xA0, "space"), Some('\u{A0}'));
// Mac Roman puts é at 0x8E.
assert_eq!(glyph(0x8E, "eacute"), Some('é'));
assert_eq!(glyph(0xAD, "notequal"), Some('≠'));
assert_eq!(glyph(0x41, "nosuchglyph"), None);
assert!(te().starts_with("[39/quotesingle 96/grave 128/Euro/.notdef/"));
assert!(te().ends_with("/ydieresis\nTE\n"));
}
}
+346 -21
View File
@@ -29,16 +29,27 @@
//! open) to stroke it too.
//! - Images (§8): `%AI5_BeginRaster`, `[matrix] 0 0 w h w h 8 3 0 0 0 0 XI`, the RGB samples in
//! hex on comment lines, `%AI5_EndRaster`.
//! - Type (§15): point type, area type and type on a path as text objects, `type To`, `a b c d tx
//! ty startPt Tp` (the text matrix; the frame or path in art space between it and `TP`), each
//! run's paint style, `Tr` (fill, stroke, both or neither), `/_font size ascent descent Tf`,
//! `Ts Tz Tt TA Tl Ta Ti` and its characters `(…) Tx` (`\r` ends a paragraph), `TO`. Only the
//! revisable form is written: readers lay the type out again (§15.1). Fonts are named by their
//! PostScript names in `%%DocumentFonts` and `%%IncludeFont` (§3.1) and re-encoded to
//! Windows-1252 (`TE`, then `%AI3_BeginEncoding: _name name`, `TZ`, §3.3), the 8-bit encoding
//! strings are in.
//!
//! The format has one artboard, no transparency, sublayers, object names, live effects, symbols
//! or appearances with several fills and strokes. Callers write one file per artboard and expand
//! appearances first; what is left is written as the format can hold it, each loss a warning:
//! transparent objects are written opaque, sublayers become groups, type its outlines, live objects and symbols their
//! art, strokes a plain line can't draw their outlines, pattern fills their tiles clipped to the
//! shape, freeform gradients their average colour, elliptical radial gradients circular (the
//! gradient matrix is relative to a canvas the specification doesn't size), transparent image
//! pixels over white. Hidden
//! objects are left out (hidden layers stay, hidden).
//! The format has one artboard, no transparency, sublayers, object names, hidden objects (only
//! layers hide), live effects, symbols or appearances with several fills and strokes. Callers write
//! one file per artboard and expand appearances first; what is left is written as the format can
//! hold it, each loss a warning: transparent objects are written opaque, sublayers become groups,
//! live objects and symbols their art, strokes a plain line can't draw their outlines, pattern
//! fills their tiles clipped to the shape, freeform gradients their average colour, elliptical
//! radial gradients circular (the gradient matrix is relative to a canvas the specification
//! doesn't size), transparent image pixels over white. Hidden objects go to a hidden layer of
//! their own above their layer, are left out or show, as [`HiddenObjects`] says (in a hidden
//! layer they stay where they are). Type that 8-bit text can't hold (characters outside
//! Windows-1252, vertical type, inline graphics, gradient or pattern fills) is written as its
//! outlines, and all type with [`LegacyAiOptions::outline_text`].
//!
//! No prolog is written: the file names none, so PostScript printers can't print it, as the `.ai`
//! files other apps write in this format.
@@ -49,12 +60,13 @@ use std::sync::Arc;
use vectorcraft_color::cms::Cms;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{
AppearanceItem, Document, FillLayer, ImageObject, LAYER_COLORS, LayerColor, LineCap, LineJoin, Node, NodeKind, StrokeLayer, TextObject,
AppearanceItem, AreaOptions, CharPosition, CharStyle, Document, FillLayer, ImageObject, Justify, LAYER_COLORS, LayerColor, LineCap, LineJoin,
Node, NodeKind, ParaDirection, ParaStyle, PathEffect, StrokeLayer, TextKind, TextObject,
};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, AnchorKind, BezPath, FillRule, PathData, Point, Rect, Shape, SubPath, Vec2};
use crate::ps;
use crate::{latin, ps};
/// How deep symbols and live objects may nest in one another (deeper art is left out).
const MAX_NEST: u32 = 8;
@@ -66,8 +78,11 @@ const MAX_IMAGE_PIXELS: u64 = 1 << 26;
const HEX_LINE: usize = 64;
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 is written as outlines (legacy .ai type isn't written yet)";
pub const TYPE: &str = "type a legacy .ai can't hold as text (characters outside Windows Latin-1, vertical type, inline graphics, gradient or pattern fills) is written as outlines";
pub const TYPE_ATTRIBUTES: &str = "type attributes legacy .ai files don't have (underline, strikethrough, small caps, superscript and subscript, character rotation, OpenType features, area type options, type on a path effects, space after paragraphs, tabs) 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)";
pub const TRANSPARENCY: &str = "transparency (opacity, blending modes, opacity masks) is written opaque: legacy .ai files have none";
pub const NAMES: &str = "object names are left out (legacy .ai files name only layers)";
const PATTERNS: &str = "pattern fills are written as their tiles, clipped to the shape";
@@ -80,13 +95,30 @@ const UNREADABLE_IMAGE: &str = "an image that can't be decoded or is too large w
const DEEP: &str = "art nested too deeply was left out";
const ELLIPTICAL: &str = "elliptical radial gradients are written circular";
/// What to write in the header.
/// What to write in the header, and how to write what the format can't hold.
#[derive(Clone, Debug, Default)]
pub struct LegacyAiOptions {
/// `%%Title`.
pub title: String,
/// `%%CreationDate`, Unix seconds.
pub created: Option<i64>,
/// Where hidden objects go (the format hides only layers).
pub hidden: HiddenObjects,
/// Type as its glyphs' outlines, not text objects.
pub outline_text: bool,
}
/// What a legacy `.ai` does with hidden objects, which the format can't mark hidden (only layers).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum HiddenObjects {
/// Each layer's hidden objects in a hidden layer of their own, just above it (they leave
/// their groups).
#[default]
Layer,
/// Left out.
Omit,
/// Written where they are, visible.
Visible,
}
/// The file and what it couldn't hold.
@@ -110,12 +142,15 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
let top = if (height - height.round()).abs() < 1e-6 { height.round() } else { height.ceil() };
let below = top - height;
let to_art = Affine::new([1.0, 0.0, 0.0, -1.0, -board.x0, board.y1 + below]);
let mut w = Writer::new(doc, to_art);
let mut w = Writer::new(doc, to_art, o);
let mut layers = 0usize;
for layer in &doc.layers {
if w.layer(layer) {
layers += 1;
}
if w.hidden_layer(layer) {
layers += 1;
}
}
let art = doc
.layers
@@ -149,6 +184,9 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
ps::push_nums(&mut s, &cmyk.map(f64::from));
let _ = writeln!(s, "{}", ps::string(name));
}
for (i, (name, _)) in w.fonts.iter().enumerate() {
let _ = writeln!(s, "{} {name}", if i == 0 { "%%DocumentFonts:" } else { "%%+" });
}
s.push_str("%AI5_FileFormat 3\n%AI3_ColorUsage: Color\n");
// An empty template box at the artboard's centre sets the ruler origin (§2.2).
let (cx, cy) = (ps::num(width / 2.0), ps::num(below + height / 2.0));
@@ -159,6 +197,7 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
s.push_str("%AI5_RulerUnits: 2\n");
let _ = writeln!(s, "%AI5_NumLayers: {layers}");
s.push_str("%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n");
fonts(&mut s, &w.fonts);
if !w.gradients.is_empty() {
let _ = writeln!(s, "{} Bn", w.gradients.len());
for (name, g) in &w.gradients {
@@ -171,6 +210,30 @@ pub fn export_legacy_ai(doc: &Document, o: &LegacyAiOptions) -> Result<LegacyAiO
Ok(LegacyAiOutput { bytes: s.into_bytes(), warnings: w.warnings })
}
/// The setup's fonts (§3.1, §3.3): `%%IncludeFont` for each, the encoding strings are in (`TE`),
/// and each font re-encoded to it as `_name` (`TZ`; a TrueType font takes the encoding as its own,
/// not the standard one, as the specification says of fonts that aren't Type 1).
fn fonts(s: &mut String, fonts: &[(String, bool)]) {
if fonts.is_empty() {
return;
}
for (name, _) in fonts {
let _ = writeln!(s, "%%IncludeFont: {name}");
}
let te = latin::te();
s.push_str(&te);
// The encoding pairs, without `[` and `TE`.
let pairs = te.trim_start_matches('[').trim_end().trim_end_matches("TE").trim_end();
for (name, truetype) in fonts {
let _ = writeln!(s, "%AI3_BeginEncoding: _{name} {name}");
if *truetype {
let _ = writeln!(s, "[{pairs}\n/_{name}/{name} 0 0 0 TZ\n%AI3_EndEncoding TrueType");
} else {
let _ = writeln!(s, "[/_{name}/{name} 0 0 1 TZ\n%AI3_EndEncoding AdobeType");
}
}
}
/// The first line of a PostScript file (the DSC version comment).
const PS_HEADER: &str = "%!PS-Adobe-3.0"; // brand-ok: the PostScript header comment
@@ -252,10 +315,21 @@ struct Writer<'a> {
nest: u32,
/// Groups and layers open.
depth: u32,
/// What to do with hidden objects.
hidden: HiddenObjects,
/// Type as outlines.
outline_text: bool,
/// The layer being written's hidden objects for its hidden layer, and whether each is locked.
hidden_art: Vec<(Node, bool)>,
/// Writing a layer that is hidden (or the hidden layer of one's hidden objects): hidden objects
/// are written where they are.
in_hidden: bool,
/// The fonts type is set in: PostScript name, and whether it's TrueType.
fonts: Vec<(String, bool)>,
}
impl<'a> Writer<'a> {
fn new(doc: &'a Document, to_art: Affine) -> Self {
fn new(doc: &'a Document, to_art: Affine, o: &LegacyAiOptions) -> Self {
Self {
doc,
to_art,
@@ -267,6 +341,11 @@ impl<'a> Writer<'a> {
locked: false,
nest: 0,
depth: 0,
hidden: o.hidden,
outline_text: o.outline_text,
hidden_art: vec![],
in_hidden: false,
fonts: vec![],
}
}
@@ -308,7 +387,45 @@ impl<'a> Writer<'a> {
);
self.locked = false;
self.line("0 A");
self.in_hidden = !n.visible;
self.contents(children, *clip, false);
self.in_hidden = false;
self.line("LB\n%AI5_EndLayer--");
true
}
/// The hidden layer of top-level layer `n`'s hidden objects, just above it (named after it,
/// with its colour, lock and printing). False when it has none.
fn hidden_layer(&mut self, n: &Node) -> bool {
if self.hidden_art.is_empty() {
return false;
}
let art = std::mem::take(&mut self.hidden_art);
let NodeKind::Layer { color, template, printable, .. } = &n.kind else { return false };
let [r, g, b] = match color {
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(*i)).map_or([79, 128, 255], |c| c.1),
LayerColor::Custom(rgb) => *rgb,
};
let index = match color {
LayerColor::Preset(i) if usize::from(*i) < LAYER_COLORS.len() => i64::from(*i),
_ => -1,
};
let name = format!("{} (hidden)", n.name.as_deref().unwrap_or("Layer"));
let _ = writeln!(
self.body,
"%AI5_BeginLayer\n0 1 {} {} {} 0 {index} {r} {g} {b} Lb\n{} Ln",
u8::from(!n.locked),
u8::from(*printable && !*template),
u8::from(*template),
ps::string(&name),
);
self.locked = false;
self.line("0 A");
self.in_hidden = true;
for (node, locked) in &art {
self.node(node, *locked);
}
self.in_hidden = false;
self.line("LB\n%AI5_EndLayer--");
true
}
@@ -334,9 +451,19 @@ impl<'a> Writer<'a> {
/// One object (inside a container locked when `locked`).
fn node(&mut self, n: &Node, locked: bool) {
if !n.visible {
self.warn(HIDDEN);
return;
if !n.visible && !self.in_hidden {
match self.hidden {
HiddenObjects::Omit => {
self.warn(HIDDEN);
return;
}
HiddenObjects::Layer => {
self.warn(HIDDEN_LAYER);
self.hidden_art.push((n.clone(), locked || n.locked));
return;
}
HiddenObjects::Visible => self.warn(HIDDEN_SHOWN),
}
}
if self.depth >= MAX_DEPTH {
self.warn(DEEP);
@@ -826,13 +953,137 @@ impl<'a> Writer<'a> {
// ---------- type ----------
/// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill
/// and stroke.
/// Type: a text object when the format can hold it as text, else its outlines.
fn text(&mut self, t: &TextObject) {
self.warn(TYPE);
if !invertible(t.xf) {
return;
}
if !self.outline_text {
if writable(t) {
return self.text_object(t);
}
self.warn(TYPE);
}
self.outlines(t);
}
/// A text object (§15): `type To`, the text path (`a b c d tx ty startPt Tp`, an area's frame
/// or the path type runs along, `TP`), then each run's style and characters, `TO`. The matrix
/// takes text space (y up, the first baseline's start for point type) to art space; the frame
/// and path are in art space, as the specification's examples have them (§15.5).
fn text_object(&mut self, t: &TextObject) {
if lost_attributes(t) {
self.warn(TYPE_ATTRIBUTES);
}
let m = self.to_art * t.xf * Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]);
let (kind, path, start) = match &t.kind {
TextKind::Point => (0, None, 0.0),
TextKind::Area { frame } => (1, frame.subpaths.iter().find(|sp| !sp.anchors.is_empty()).cloned(), 0.0),
TextKind::OnPath { path, .. } => {
let sp = path.subpaths.iter().find(|sp| !sp.anchors.is_empty()).cloned();
let fraction = t.kind.path_span().map_or(0.0, |(s, _)| s);
let start = sp
.as_ref()
.map_or(0.0, |sp| segment_point(&PathData::new(vec![sp.clone()]).transformed(self.to_art * t.xf).to_bezpath(), fraction));
(2, sp, start)
}
};
let _ = writeln!(self.body, "{kind} To");
let [a, b, c, d, e, f] = m.as_coeffs();
ps::push_nums(&mut self.body, &[a, b, c, d, e, f, start]);
self.line("Tp");
if let Some(sp) = path {
let placed = SubPath::new(sp.anchors.iter().map(|a| a.transform(t.xf)).collect(), sp.closed);
self.subpath(&placed);
self.line(if sp.closed { "n" } else { "N" });
}
self.line("TP");
let mut para = 0usize;
let mut para_start = true;
for run in &t.runs {
let text = if run.style.all_caps { run.text.to_uppercase() } else { run.text.clone() };
let mut pieces = text.split('\n').peekable();
while let Some(piece) = pieces.next() {
let ends_paragraph = pieces.peek().is_some();
if piece.is_empty() && !ends_paragraph {
continue;
}
if para_start {
self.paragraph(t.para_at(para));
para_start = false;
}
self.char_style(&run.style, t.para_at(para));
let mut bytes: Vec<u8> = piece.chars().filter_map(latin::encode).collect();
if ends_paragraph {
bytes.push(b'\r');
para += 1;
para_start = true;
}
self.body.push_str(&ps::bytes(&bytes));
self.line(" Tx");
}
}
self.line("TO");
}
/// A paragraph's attributes: alignment `Ta` and indents `Ti` (first line's, left, right).
fn paragraph(&mut self, p: &ParaStyle) {
let align = match p.justify {
Justify::Auto if p.direction == Some(ParaDirection::RightToLeft) => 2,
Justify::Auto | Justify::Left => 0,
Justify::Center => 1,
Justify::Right => 2,
Justify::JustifyLeft | Justify::JustifyCenter | Justify::JustifyRight => 3,
Justify::JustifyAll => 4,
};
let _ = writeln!(self.body, "{align} Ta");
ps::push_nums(&mut self.body, &[p.first_line_indent, p.left_indent, p.right_indent]);
self.line("Ti");
}
/// A run's character attributes and paint style: `Tr`, its fill and stroke, `Tf`, `Ts`, `Tz`,
/// `Tt`, `TA`, and the leading with the space before its paragraph, `Tl`.
fn char_style(&mut self, st: &CharStyle, p: &ParaStyle) {
let fill = !st.fill.is_none();
let stroke = st.has_stroke();
let render = match (fill, stroke) {
(true, false) => 0,
(false, true) => 1,
(true, true) => 2,
(false, false) => 3,
};
let _ = writeln!(self.body, "{render} Tr");
if fill {
self.color(&st.fill, false);
self.line(if st.overprint_fill { "1 O" } else { "0 O" });
}
if stroke {
self.color(&st.stroke, true);
self.line(if st.overprint_stroke { "1 R" } else { "0 R" });
self.line_style(&st.stroke_layer());
}
let (name, truetype, ascent, descent) = font_of(st);
if !self.fonts.iter().any(|(n, _)| *n == name) {
self.fonts.push((name.clone(), truetype));
}
let size = finite(st.size, 12.0);
let _ = write!(self.body, "/_{name} ");
ps::push_nums(&mut self.body, &[size, ascent * size, descent * size]);
self.line("Tf");
ps::push_num(&mut self.body, finite(st.baseline_shift, 0.0));
self.line(" Ts");
ps::push_nums(&mut self.body, &[finite(st.h_scale, 100.0), finite(st.v_scale, 100.0)]);
self.line("Tz");
ps::push_num(&mut self.body, finite(st.tracking, 0.0));
self.line(" Tt");
self.line(if st.kerning.is_none() { "1 TA" } else { "0 TA" });
ps::push_nums(&mut self.body, &[finite(st.effective_leading(), size * 1.2), finite(p.space_before, 0.0)]);
self.line("Tl");
}
/// Type as its glyphs' outlines: a group of a compound path for each run, with the run's fill
/// and stroke.
fn outlines(&mut self, t: &TextObject) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let scale = t.xf.determinant().abs().sqrt();
self.depth += 1;
@@ -925,6 +1176,80 @@ impl<'a> Writer<'a> {
}
}
/// Can type `t` be written as text: horizontal, its runs plain characters (no inline graphics)
/// that 8-bit text can hold, filled and stroked with colours?
fn writable(t: &TextObject) -> bool {
let solid = |p: &Paint| matches!(p, Paint::Solid { .. } | Paint::None);
!t.vertical
&& t.runs.iter().any(|r| !r.text.is_empty())
&& t.runs.iter().all(|r| {
r.inline.is_none()
&& solid(&r.style.fill)
&& solid(&r.style.stroke)
&& (if r.style.all_caps { r.text.to_uppercase() } else { r.text.clone() }).chars().all(|c| latin::encode(c).is_some())
})
}
/// Has type `t` attributes a legacy `.ai` text object can't hold (lost when it's written as text)?
fn lost_attributes(t: &TextObject) -> bool {
let run_lost = |s: &CharStyle| {
s.underline || s.strikethrough || s.small_caps.is_some() || s.position != CharPosition::Normal || s.rotation != 0.0 || !s.features.is_empty()
};
let para_lost = |p: &ParaStyle| p.space_after != 0.0 || !p.tabs.is_empty();
t.runs.iter().any(|r| run_lost(&r.style))
|| t.paragraph_styles().iter().any(para_lost)
|| (matches!(t.kind, TextKind::Area { .. }) && t.area != AreaOptions::default())
|| (matches!(t.kind, TextKind::OnPath { .. }) && (t.path_effect != PathEffect::default() || t.path_spacing != 0.0))
}
/// The font type in style `st` is set in: its PostScript name, whether it's TrueType, and its
/// ascent and descent (ems). A font that isn't installed is named after its family and style.
fn font_of(st: &CharStyle) -> (String, bool, f64, f64) {
let db = vectorcraft_text::FontDb::global();
match db.face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) {
Some(face) => {
let (ascent, descent) = face.vertical_metrics();
let em = face.units_per_em().max(1.0);
let name = face.postscript_name();
(name, face.is_truetype(), ascent / em, descent / em)
}
None => {
let squeeze = |s: &str| -> String { s.chars().filter(|c| c.is_ascii_alphanumeric()).collect() };
let style = squeeze(&st.font_style);
let name =
if style.is_empty() || style == "Regular" { squeeze(&st.font_family) } else { format!("{}-{style}", squeeze(&st.font_family)) };
(if name.is_empty() { "Helvetica".into() } else { name }, false, 0.75, 0.25)
}
}
}
/// `v`, or `default` when it isn't finite.
fn finite(v: f64, default: f64) -> f64 {
if v.is_finite() { v } else { default }
}
/// Where a fraction of `path`'s length is, as the start of type on a path gives it (§15.5.2): the
/// index of the segment it's on, plus how far along that segment (its curve parameter).
fn segment_point(path: &BezPath, fraction: f64) -> f64 {
use kurbo::ParamCurveArclen;
const ACCURACY: f64 = 1e-3;
let segments: Vec<kurbo::PathSeg> = path.segments().collect();
let lengths: Vec<f64> = segments.iter().map(|s| s.arclen(ACCURACY)).collect();
let total: f64 = lengths.iter().sum();
if !(total.is_finite() && total > 0.0) {
return 0.0;
}
let mut left = fraction.clamp(0.0, 1.0) * total;
for (i, (seg, len)) in segments.iter().zip(&lengths).enumerate() {
if left <= *len || i + 1 == segments.len() {
let t = if *len > 0.0 { seg.inv_arclen(left.min(*len), ACCURACY) } else { 0.0 };
return i as f64 + t.clamp(0.0, 0.9999);
}
left -= len;
}
0.0
}
/// Can `p` paint a path object's fill directly (a colour or a linear or radial gradient)?
fn direct_fill(p: &Paint) -> bool {
match p {
+2 -1
View File
@@ -30,6 +30,7 @@
//! - `legacy_ai`: legacy `.ai` files of version 7 ([`export_legacy_ai`]), with their layers and groups.
mod import;
mod latin;
mod legacy_ai;
mod print;
mod ps;
@@ -37,7 +38,7 @@ mod scene;
mod tiff;
pub use import::{Imported, ai_alone, family_style, import, import_with, is_loss, layered_ai};
pub use legacy_ai::{LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
pub use legacy_ai::{HiddenObjects, LegacyAiOptions, LegacyAiOutput, export_legacy_ai};
pub use print::{PrintJob, PrintPage, print};
use vectorcraft_doc::Document;
+24
View File
@@ -73,6 +73,30 @@ pub(crate) fn string(text: &str) -> String {
s
}
/// `bytes` as a PostScript string: `(` `)` `\` escaped, carriage returns, line feeds and tabs as
/// `\r` `\n` `\t`, other bytes outside printable ASCII as octal escapes.
pub(crate) fn bytes(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() + 2);
s.push('(');
for &b in bytes {
match b {
b'(' | b')' | b'\\' => {
s.push('\\');
s.push(char::from(b));
}
b'\r' => s.push_str("\\r"),
b'\n' => s.push_str("\\n"),
b'\t' => s.push_str("\\t"),
0x20..=0x7e => s.push(char::from(b)),
_ => {
let _ = write!(s, "\\{b:03o}");
}
}
}
s.push(')');
s
}
/// A matrix operand `[a b c d e f]`.
pub(crate) fn matrix(a: Affine) -> String {
let mut s = String::from("[");
+183 -2
View File
@@ -73,7 +73,11 @@ fn sample() -> Document {
}
fn write(doc: &Document) -> LegacyAiOutput {
export_legacy_ai(doc, &LegacyAiOptions { title: "Sample".into(), created: Some(0) }).unwrap()
write_with(doc, LegacyAiOptions::default())
}
fn write_with(doc: &Document, o: LegacyAiOptions) -> LegacyAiOutput {
export_legacy_ai(doc, &LegacyAiOptions { title: "Sample".into(), created: Some(0), ..o }).unwrap()
}
fn children(n: &Node) -> &[Arc<Node>] {
@@ -236,7 +240,7 @@ fn losses_are_warned() {
named.opacity = 0.5;
doc.insert(Some(sub), usize::MAX, named).unwrap();
let w = write(&doc).warnings;
for expected in [SUBLAYERS, HIDDEN, NAMES, TRANSPARENCY] {
for expected in [SUBLAYERS, HIDDEN_LAYER, NAMES, TRANSPARENCY] {
assert!(w.iter().any(|x| x == expected), "{expected} not in {w:?}");
}
// The sublayer is a group in its layer.
@@ -285,3 +289,180 @@ fn needs_an_artboard() {
doc.artboards.clear();
assert!(export_legacy_ai(&doc, &LegacyAiOptions::default()).is_err());
}
/// Every text object of `doc`'s layers, in paint order.
fn texts(doc: &Document) -> Vec<(Node, vectorcraft_doc::TextObject)> {
let mut out = vec![];
doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind {
out.push((n.clone(), (**t).clone()));
}
});
out
}
fn style(size: f64, fill: Paint) -> vectorcraft_doc::CharStyle {
vectorcraft_doc::CharStyle { size, fill, ..vectorcraft_doc::CharStyle::default() }
}
/// A document of point type (two paragraphs, the second centred, accented letters and a euro
/// sign), area type in a 100 × 50 frame and type on a path starting a quarter of the way along.
fn typed() -> Document {
use vectorcraft_doc::{Justify, ParaStyle, TextKind, TextObject, TextRun};
let mut doc = Document::new(300.0, 200.0);
let layer = doc.layers[0].id;
let mut point = TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Héllo €5\nWorld", style(18.0, red()));
point.runs.push(TextRun::new(" again", style(12.0, Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)))));
point.set_paragraph_styles(vec![ParaStyle::default(), ParaStyle { justify: Justify::Center, ..ParaStyle::default() }]);
let mut area = TextObject::point(vectorcraft_geom::Point::ZERO, "Area type flows in its frame", style(10.0, Paint::solid(Color::BLACK)));
area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)) };
area.xf = Affine::translate((20.0, 80.0));
let mut on_path = TextObject::point(vectorcraft_geom::Point::ZERO, "Along a path", style(12.0, Paint::solid(Color::gray(0.5))));
let mut line = PathData::new(vec![]);
line.subpaths.push(vectorcraft_geom::SubPath::polyline(&[(150.0, 150.0).into(), (250.0, 150.0).into(), (250.0, 190.0).into()], false));
on_path.kind = TextKind::OnPath { path: line, start: 0.25, end: None };
on_path.xf = Affine::IDENTITY;
for t in [point, area, on_path] {
let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t)));
doc.insert(Some(layer), usize::MAX, n).unwrap();
}
doc
}
#[test]
fn type_is_written_as_text_objects() {
let out = write(&typed());
let text = String::from_utf8(out.bytes).unwrap();
for needle in [
"%%DocumentFonts: ",
"%%IncludeFont: ",
"\nTE\n",
"%AI3_BeginEncoding: _",
"0 To\n1 0 0 1 20 160 0 Tp\nTP\n",
"1 To\n",
"2 To\n",
" Tf\n",
"TO\n",
] {
assert!(text.contains(needle), "missing {needle:?} in\n{text}");
}
// Windows-1252: é is 351 (octal), the euro sign 200, the paragraph ends with `\r`.
assert!(text.contains("(H\\351llo \\2005\\r) Tx\n"), "{text}");
// The second paragraph centred.
assert!(text.contains("1 Ta\n"), "{text}");
// The area's frame in art space, painted with nothing.
assert!(text.contains("20 120 m\n"), "{text}");
assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
}
#[test]
fn type_reads_back_as_text() {
use vectorcraft_doc::{Justify, TextKind};
let imported = crate::import(&write(&typed()).bytes).unwrap();
let found = texts(&imported.document);
assert_eq!(found.len(), 3, "{:?}", imported.warnings);
let squeeze = |s: &str| s.replace(' ', "");
// Point type: the characters, paragraphs, sizes, colours and place.
let (_, point) = &found[0];
assert!(matches!(point.kind, TextKind::Point));
assert_eq!(point.plain_text(), "Héllo €5\nWorld again");
assert_eq!(point.runs.len(), 2, "{:?}", point.runs);
let first = &point.runs[0].style;
assert_eq!(squeeze(&first.font_family), "SourceSans3", "{first:?}");
assert!((first.size - 18.0).abs() < 1e-6 && first.leading.is_none(), "{first:?}");
assert_eq!(first.fill.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
assert_eq!(point.runs[1].style.fill.color(), Some(Color::cmyk(1.0, 0.0, 0.0, 0.0)));
assert!((point.runs[1].style.size - 12.0).abs() < 1e-6);
assert_eq!(point.para_at(0).justify, Justify::Left);
assert_eq!(point.para_at(1).justify, Justify::Center);
let origin = point.xf * vectorcraft_geom::Point::ZERO;
assert!((origin.x - 20.0).abs() < 1e-3 && (origin.y - 40.0).abs() < 1e-3, "{:?}", point.xf);
// Area type: its frame where it was.
let (_, area) = &found[1];
let TextKind::Area { frame } = &area.kind else { panic!("{:?}", area.kind) };
let b = frame.transformed(area.xf).to_bezpath().bounding_box();
assert!((b.x0 - 20.0).abs() < 1e-3 && (b.y0 - 80.0).abs() < 1e-3 && (b.x1 - 120.0).abs() < 1e-3 && (b.y1 - 130.0).abs() < 1e-3, "{b:?}");
assert_eq!(area.plain_text(), "Area type flows in its frame");
// Type on a path: its path and where along it the type starts.
let (_, along) = &found[2];
let TextKind::OnPath { path, start, .. } = &along.kind else { panic!("{:?}", along.kind) };
assert!((start - 0.25).abs() < 1e-2, "{start}");
let b = path.transformed(along.xf).to_bezpath().bounding_box();
assert!((b.x0 - 150.0).abs() < 1e-3 && (b.y1 - 190.0).abs() < 1e-3, "{b:?}");
}
#[test]
fn type_8_bit_text_cant_hold_and_type_asked_for_as_outlines_are_outlines() {
use vectorcraft_doc::TextObject;
let mut doc = Document::new(200.0, 100.0);
let layer = doc.layers[0].id;
let t = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "Ωmega 日本", style(12.0, red()));
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!(out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
assert!(!String::from_utf8_lossy(&out.bytes).contains(" To\n"));
// Type that could be text, asked for as outlines.
let out = write_with(&typed(), LegacyAiOptions { outline_text: true, ..LegacyAiOptions::default() });
let text = String::from_utf8(out.bytes).unwrap();
assert!(!text.contains(" To\n") && !text.contains("%%DocumentFonts"), "{text}");
assert!(!out.warnings.iter().any(|w| w == TYPE), "{:?}", out.warnings);
}
/// A layer `Art` holding a group of a shown square and a hidden one; a hidden layer `Off` holding
/// a hidden square.
fn with_hidden() -> Document {
let mut doc = Document::new(200.0, 100.0);
doc.layers.clear();
let art = doc.add_layer(Some("Art"));
let off = doc.add_layer(Some("Off"));
let shown = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), red(), Paint::None);
let mut hidden = shape(&mut doc, shapes::rectangle(Rect::new(50.0, 10.0, 80.0, 40.0)), red(), Paint::None);
hidden.visible = false;
hidden.locked = true;
let group = Node::group(doc.alloc_id(), vec![Arc::new(shown), Arc::new(hidden)]);
doc.insert(Some(art), usize::MAX, group).unwrap();
let mut also = shape(&mut doc, shapes::rectangle(Rect::new(100.0, 10.0, 140.0, 40.0)), red(), Paint::None);
also.visible = false;
doc.insert(Some(off), usize::MAX, also).unwrap();
if let Some(l) = doc.layers.get_mut(1) {
Arc::make_mut(l).visible = false;
}
doc
}
#[test]
fn hidden_objects_go_to_a_hidden_layer_above_their_own() {
let out = write(&with_hidden());
assert!(out.warnings.iter().any(|w| w == HIDDEN_LAYER), "{:?}", out.warnings);
let doc = crate::import(&out.bytes).unwrap().document;
let names: Vec<_> = doc.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect();
assert_eq!(names, ["Art", "Art (hidden)", "Off"]);
assert!(doc.layers[0].visible && !doc.layers[1].visible && !doc.layers[2].visible);
// The group keeps the shown square; the hidden one, still locked, is in the hidden layer.
let group = &children(&doc.layers[0])[0];
assert_eq!(children(group).len(), 1);
let moved = children(&doc.layers[1]);
assert_eq!(moved.len(), 1);
assert!(moved[0].locked);
let b = moved[0].visual_bounds().unwrap();
assert!((b.x0 - 50.0).abs() < 0.01, "{b:?}");
// In a hidden layer, hidden objects stay where they are.
assert_eq!(children(&doc.layers[2]).len(), 1);
}
#[test]
fn hidden_objects_can_be_left_out_or_shown() {
let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Omit, ..LegacyAiOptions::default() });
assert!(out.warnings.iter().any(|w| w == HIDDEN), "{:?}", out.warnings);
let doc = crate::import(&out.bytes).unwrap().document;
assert_eq!(doc.layers.len(), 2);
assert_eq!(children(&children(&doc.layers[0])[0]).len(), 1);
let out = write_with(&with_hidden(), LegacyAiOptions { hidden: HiddenObjects::Visible, ..LegacyAiOptions::default() });
assert!(out.warnings.iter().any(|w| w == HIDDEN_SHOWN), "{:?}", out.warnings);
let doc = crate::import(&out.bytes).unwrap().document;
assert_eq!(doc.layers.len(), 2);
let pair = children(&children(&doc.layers[0])[0]);
assert_eq!(pair.len(), 2);
assert!(pair.iter().all(|n| n.visible));
}
+19
View File
@@ -289,6 +289,25 @@ impl FontFace {
pub fn face_index(&self) -> u32 {
self.index
}
/// The face's PostScript name, as PostScript files name fonts (`SourceSans3-Bold`): name ID 6;
/// for a named instance of a variable font (whose name ID 6 is the default instance's), its
/// prefix and style (`SourceSans3-Semibold`). Without one, its family and style.
pub fn postscript_name(&self) -> String {
let squeeze = |s: &str| -> String { s.chars().filter(|c| c.is_ascii_graphic() && !"[](){}<>/%".contains(*c)).collect() };
let own = self.skrifa().and_then(|f| localized(&f, StringId::POSTSCRIPT_NAME).into_iter().map(|(_, s)| s).next()).map(|s| squeeze(&s));
match own.filter(|s| !s.is_empty()) {
Some(name) if self.variations.is_empty() => name,
own => {
let prefix = own.as_deref().and_then(|n| n.split('-').next()).map_or_else(|| squeeze(&self.family), str::to_string);
format!("{prefix}-{}", squeeze(&self.style))
}
}
}
/// Are the face's outlines TrueType (`glyf`), not PostScript (`CFF `)?
pub fn is_truetype(&self) -> bool {
use skrifa::raw::TableProvider;
self.skrifa().is_some_and(|f| f.glyf().is_ok())
}
}
/// One installed family found by the system font scan: its name and the file of each style.