From 6ce1a622be9e7ef740262c292b47d609501f21e8 Mon Sep 17 00:00:00 2001 From: Dan Green Date: Mon, 12 Oct 2026 21:20:38 -0700 Subject: [PATCH] Part of #868: legacy .ai type as text objects and hidden objects kept; legacy type read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/engine/src/cmd/fileio/legacy_ai.rs | 44 ++- crates/engine/src/cmd/fileio/native.rs | 14 +- crates/engine/src/tests_legacy_ai.rs | 62 +++- crates/engine/tests/import_fuzz.rs | 31 ++ crates/eps/src/import/ai/legacy_text.rs | 310 ++++++++++++++++++ crates/eps/src/import/ai/read.rs | 35 ++- crates/eps/src/latin.rs | 255 +++++++++++++++ crates/eps/src/legacy_ai.rs | 367 ++++++++++++++++++++-- crates/eps/src/lib.rs | 3 +- crates/eps/src/ps.rs | 24 ++ crates/eps/src/tests_legacy_ai.rs | 185 ++++++++++- crates/text/src/fontdb.rs | 19 ++ 12 files changed, 1312 insertions(+), 37 deletions(-) create mode 100644 crates/eps/src/import/ai/legacy_text.rs create mode 100644 crates/eps/src/latin.rs diff --git a/crates/engine/src/cmd/fileio/legacy_ai.rs b/crates/engine/src/cmd/fileio/legacy_ai.rs index e0961b61c..5d1df1493 100644 --- a/crates/engine/src/cmd/fileio/legacy_ai.rs +++ b/crates/engine/src/cmd/fileio/legacy_ai.rs @@ -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 { + 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 { + 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 { match p.get("transparentImages") { None | Some(Value::Null) => Ok(SeeThrough::Flatten), @@ -55,7 +82,7 @@ fn see_through(cmd: &str, p: &Value) -> Result { } /// 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 } diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 2874b95a7..681b2fd5f 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -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)] diff --git a/crates/engine/src/tests_legacy_ai.rs b/crates/engine/src/tests_legacy_ai.rs index 79f41c822..fef2b44cf 100644 --- a/crates/engine/src/tests_legacy_ai.rs +++ b/crates/engine/src/tests_legacy_ai.rs @@ -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 { + 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] diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 6c8737cb9..8da0b4e30 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2152,6 +2152,37 @@ fn arb_ai_token() -> impl Strategy { "\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", diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs new file mode 100644 index 000000000..126619c13 --- /dev/null +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -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, + /// 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, + runs: Vec, + /// Each paragraph's attributes, as they were at its first character. + paras: Vec, + /// 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>, + /// 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 { + 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 = 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 = 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 = path.segments().collect(); + let lengths: Vec = 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) +} diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index f507696a5..6e65c9e3e 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -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, /// The tile's top left (y down) of each pattern read, by name. pattern_tiles: HashMap, + /// The legacy text object being read (`To` … `TO`), and the modal type attributes. + text: Option, + 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() { diff --git a/crates/eps/src/latin.rs b/crates/eps/src/latin.rs new file mode 100644 index 000000000..be8c5e98b --- /dev/null +++ b/crates/eps/src/latin.rs @@ -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 { + 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 { + 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")); + } +} diff --git a/crates/eps/src/legacy_ai.rs b/crates/eps/src/legacy_ai.rs index de2cc5b0a..6904fb0d5 100644 --- a/crates/eps/src/legacy_ai.rs +++ b/crates/eps/src/legacy_ai.rs @@ -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, + /// 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 Result Result Result { 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 = 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 = path.segments().collect(); + let lengths: Vec = 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 { diff --git a/crates/eps/src/lib.rs b/crates/eps/src/lib.rs index eb972210d..6328964c2 100644 --- a/crates/eps/src/lib.rs +++ b/crates/eps/src/lib.rs @@ -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; diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index fb27425ed..b00c1fbab 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -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("["); diff --git a/crates/eps/src/tests_legacy_ai.rs b/crates/eps/src/tests_legacy_ai.rs index ce8c0c206..5930aa125 100644 --- a/crates/eps/src/tests_legacy_ai.rs +++ b/crates/eps/src/tests_legacy_ai.rs @@ -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] { @@ -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)); +} diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 9b1c89a65..4466d0a12 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -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.