mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("[");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user