mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
Area type whose frame is too short for a line of the font it shows in here drew nothing: a font that isn't available shows in the fallback font, whose line may be taller than a frame made for one line of the file's font (overset type isn't drawn). Where the file itself lays lines out in the frame, the frame of the imported text object is made taller, from its top, by what its first line needs, and the open result says how many frames were.
919 lines
38 KiB
Rust
919 lines
38 KiB
Rust
//! The text of an Illustrator editing copy.
|
|
//!
|
|
//! The editing copy of a file keeps the characters of its text objects, with their fonts, sizes,
|
|
//! colours and where they sit, in one text document of its own: between `%AI11_BeginTextDocument`
|
|
//! and `%AI11_EndTextDocument`, ASCII85 of nested dictionaries with numbers for keys. A text object
|
|
//! in a layer is only a stub naming its story in it (`/StoryIndex`).
|
|
//!
|
|
//! The format isn't documented. What is read here was worked out from the files of the project's
|
|
//! users, used locally and never committed, by comparing it with the type their pages draw: the
|
|
//! meaning of each key below was checked against the page's own type in more than a thousand text
|
|
//! objects, and it is only used for the keys that matched. A story the reader doesn't recognise (area
|
|
//! type, type on a path, anything with a frame that has more than a point and a matrix) is left
|
|
//! alone: it has no text, and the importer says so.
|
|
//!
|
|
//! Where type sits: a story lays its lines out round a point `F` (the anchor: the start of a line of
|
|
//! left-aligned type, the middle of a centred one, the end of a right-aligned one), each line with an
|
|
//! offset from it. The frame of the story gives a matrix that carries that layout onto the canvas of
|
|
//! the app, which is 16383 points a side; the canvas's centre, 8191.5, is the centre of the file's
|
|
//! `%AI3_TemplateBox`. See [`Story::place`].
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
|
|
use vectorcraft_color::{Color, Paint};
|
|
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextKind, TextObject, TextRun};
|
|
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
|
|
|
/// Longest story read (bytes).
|
|
const MAX_STORY: usize = 1 << 20;
|
|
/// Biggest text document read (bytes, decoded).
|
|
const MAX_BYTES: usize = 64 << 20;
|
|
/// Deepest nesting read.
|
|
const MAX_DEPTH: usize = 64;
|
|
/// Most values read.
|
|
const MAX_VALUES: usize = 4_000_000;
|
|
/// The centre of the app's canvas, in the units of its text document.
|
|
pub(super) const CANVAS_CENTRE: f64 = 8191.5;
|
|
|
|
/// A value of the text document.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub(super) enum Val {
|
|
Num(f64),
|
|
Str(String),
|
|
Name(String),
|
|
Bool(bool),
|
|
List(Vec<Val>),
|
|
Dict(BTreeMap<String, Val>),
|
|
}
|
|
|
|
impl Val {
|
|
fn get(&self, key: &str) -> Option<&Val> {
|
|
match self {
|
|
Val::Dict(d) => d.get(key),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn num(&self) -> Option<f64> {
|
|
match self {
|
|
Val::Num(n) if n.is_finite() => Some(*n),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn str(&self) -> Option<&str> {
|
|
match self {
|
|
Val::Str(s) => Some(s),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn list(&self) -> &[Val] {
|
|
match self {
|
|
Val::List(l) => l,
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
fn nums(&self) -> Option<Vec<f64>> {
|
|
self.list().iter().map(Val::num).collect()
|
|
}
|
|
|
|
/// The `name` entry of a dictionary that is a typed node (`/99 /name`).
|
|
fn is_node(&self, name: &str) -> bool {
|
|
matches!(self.get("99"), Some(Val::Name(n)) if n == name)
|
|
}
|
|
}
|
|
|
|
struct Reader<'a> {
|
|
b: &'a [u8],
|
|
at: usize,
|
|
budget: usize,
|
|
}
|
|
|
|
impl Reader<'_> {
|
|
fn skip_space(&mut self) {
|
|
while self.b.get(self.at).is_some_and(u8::is_ascii_whitespace) {
|
|
self.at += 1;
|
|
}
|
|
}
|
|
|
|
fn value(&mut self, depth: usize) -> Option<Val> {
|
|
self.skip_space();
|
|
self.budget = self.budget.checked_sub(1)?;
|
|
let c = *self.b.get(self.at)?;
|
|
match c {
|
|
b'<' if self.b.get(self.at + 1) == Some(&b'<') => {
|
|
if depth > MAX_DEPTH {
|
|
return None;
|
|
}
|
|
self.at += 2;
|
|
let mut d = BTreeMap::new();
|
|
loop {
|
|
self.skip_space();
|
|
if self.b.get(self.at..self.at + 2) == Some(b">>") {
|
|
self.at += 2;
|
|
return Some(Val::Dict(d));
|
|
}
|
|
let key = self.key()?;
|
|
let v = self.value(depth + 1)?;
|
|
d.insert(key, v);
|
|
}
|
|
}
|
|
b'[' => {
|
|
if depth > MAX_DEPTH {
|
|
return None;
|
|
}
|
|
self.at += 1;
|
|
let mut l = vec![];
|
|
loop {
|
|
self.skip_space();
|
|
if self.b.get(self.at) == Some(&b']') {
|
|
self.at += 1;
|
|
return Some(Val::List(l));
|
|
}
|
|
l.push(self.value(depth + 1)?);
|
|
}
|
|
}
|
|
b'(' => self.string(),
|
|
b'/' => Some(Val::Name(self.key()?)),
|
|
_ => {
|
|
let start = self.at;
|
|
while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) {
|
|
self.at += 1;
|
|
}
|
|
let word = std::str::from_utf8(self.b.get(start..self.at)?).ok()?;
|
|
match word {
|
|
"true" => Some(Val::Bool(true)),
|
|
"false" => Some(Val::Bool(false)),
|
|
"" => None,
|
|
w => Some(Val::Num(w.parse().ok()?)),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A `/name`, without its slash.
|
|
fn key(&mut self) -> Option<String> {
|
|
self.skip_space();
|
|
if self.b.get(self.at) != Some(&b'/') {
|
|
return None;
|
|
}
|
|
self.at += 1;
|
|
let start = self.at;
|
|
while self.b.get(self.at).is_some_and(|c| !c.is_ascii_whitespace() && !matches!(c, b'<' | b'>' | b'[' | b']' | b'(' | b')' | b'/')) {
|
|
self.at += 1;
|
|
}
|
|
Some(String::from_utf8_lossy(self.b.get(start..self.at)?).into_owned())
|
|
}
|
|
|
|
/// A string: up to the first `)` that isn't escaped; UTF-16 when it starts with a byte order mark.
|
|
fn string(&mut self) -> Option<Val> {
|
|
self.at += 1;
|
|
let mut bytes = vec![];
|
|
loop {
|
|
let c = *self.b.get(self.at)?;
|
|
self.at += 1;
|
|
match c {
|
|
b')' => break,
|
|
b'\\' => {
|
|
let e = *self.b.get(self.at)?;
|
|
self.at += 1;
|
|
bytes.push(match e {
|
|
b'n' => b'\n',
|
|
b'r' => b'\r',
|
|
b't' => b'\t',
|
|
b'b' => 8,
|
|
b'f' => 12,
|
|
b'0'..=b'7' => {
|
|
let mut v = u32::from(e - b'0');
|
|
for _ in 0..2 {
|
|
match self.b.get(self.at) {
|
|
Some(d @ b'0'..=b'7') => {
|
|
v = v * 8 + u32::from(d - b'0');
|
|
self.at += 1;
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
(v & 0xff) as u8
|
|
}
|
|
other => other,
|
|
});
|
|
}
|
|
c => bytes.push(c),
|
|
}
|
|
if bytes.len() > MAX_BYTES {
|
|
return None;
|
|
}
|
|
}
|
|
Some(Val::Str(match bytes.strip_prefix(&[0xfe, 0xff]) {
|
|
Some(rest) => {
|
|
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect();
|
|
String::from_utf16_lossy(&units)
|
|
}
|
|
None => bytes.iter().map(|b| char::from(*b)).collect(),
|
|
}))
|
|
}
|
|
}
|
|
|
|
/// The values of a text document: its top-level `/key value` pairs.
|
|
fn read(bytes: &[u8]) -> Option<Val> {
|
|
let mut r = Reader { b: bytes, at: 0, budget: MAX_VALUES };
|
|
let mut d = BTreeMap::new();
|
|
loop {
|
|
r.skip_space();
|
|
if r.at >= bytes.len() {
|
|
return Some(Val::Dict(d));
|
|
}
|
|
let key = r.key()?;
|
|
let v = r.value(0)?;
|
|
d.insert(key, v);
|
|
}
|
|
}
|
|
|
|
/// Most frames read of one story, and most path segments of a frame.
|
|
const MAX_FRAMES: usize = 4096;
|
|
const MAX_SEGMENTS: usize = 1 << 16;
|
|
|
|
/// Where a frame puts its story's text.
|
|
#[derive(Debug, Clone)]
|
|
enum FrameKind {
|
|
/// Point type: the matrix carries the layout (its anchor at the origin of its lines) onto the
|
|
/// canvas.
|
|
Point([f64; 6]),
|
|
/// Area type flowed inside a path: its cubic segments on the canvas, four points each.
|
|
Area(Vec<[f64; 8]>),
|
|
/// Type on a path, from `start` to `end` (segment index and the place in that segment).
|
|
OnPath { segments: Vec<[f64; 8]>, start: f64, end: f64 },
|
|
}
|
|
|
|
/// A paragraph's attributes, as the text document keeps them.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
struct Para {
|
|
justify: u8,
|
|
first_indent: f64,
|
|
left_indent: f64,
|
|
right_indent: f64,
|
|
space_before: f64,
|
|
space_after: f64,
|
|
hyphenate: bool,
|
|
}
|
|
|
|
/// The text of one story, in the units of the text document.
|
|
#[derive(Debug)]
|
|
pub(super) struct Story {
|
|
/// The characters, with `\r` ending paragraphs and `\x03` ending lines.
|
|
text: String,
|
|
/// The anchor of the first frame's layout.
|
|
anchor: (f64, f64),
|
|
/// Where each of the first frame's lines starts, from the anchor.
|
|
lines: Vec<(f64, f64)>,
|
|
/// The frames the story flows through, in order.
|
|
frames: Vec<FrameKind>,
|
|
/// `(characters, paragraph)` in order.
|
|
paras: Vec<(usize, Para)>,
|
|
/// `(characters, style)` in order.
|
|
runs: Vec<(usize, Style)>,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
struct Style {
|
|
font: Option<String>,
|
|
size: f64,
|
|
/// `None`: auto.
|
|
leading: Option<f64>,
|
|
tracking: f64,
|
|
baseline_shift: f64,
|
|
h_scale: f64,
|
|
v_scale: f64,
|
|
fill: Option<Color>,
|
|
stroke: Option<Color>,
|
|
stroke_width: f64,
|
|
}
|
|
|
|
/// The text document of an editing copy.
|
|
pub(super) struct Texts {
|
|
stories: Vec<Val>,
|
|
fonts: Vec<String>,
|
|
/// The character and paragraph styles a story starts from.
|
|
defaults: Val,
|
|
para_defaults: Val,
|
|
frames: Vec<Val>,
|
|
}
|
|
|
|
impl Texts {
|
|
/// The text document in the editing copy `data`, if it has one that can be read.
|
|
pub(super) fn read(data: &[u8]) -> Option<Texts> {
|
|
fn find(h: &[u8], n: &[u8], from: usize) -> Option<usize> {
|
|
h.get(from..)?.windows(n.len()).position(|w| w == n).map(|p| p + from)
|
|
}
|
|
let start = find(data, b"%AI11_BeginTextDocument", 0)?;
|
|
let from = find(data, b"/ASCII85Decode", start)? + b"/ASCII85Decode".len();
|
|
let end = find(data, b"~>", from)? + 2;
|
|
// Each line after the first starts with a `%`.
|
|
// ASCII85 makes at most four bytes of a character (`z`): more than this would be over `MAX_BYTES`.
|
|
if end - from > MAX_BYTES / 4 {
|
|
return None;
|
|
}
|
|
let packed: String = String::from_utf8_lossy(data.get(from..end)?)
|
|
.split(['\r', '\n'])
|
|
.enumerate()
|
|
.map(|(i, l)| if i > 0 { l.strip_prefix('%').unwrap_or(l) } else { l })
|
|
.collect::<String>()
|
|
.trim_start_matches([' ', ','])
|
|
.to_string();
|
|
let bytes = crate::ps::ascii85_decode(&packed)?;
|
|
if bytes.len() > MAX_BYTES {
|
|
return None;
|
|
}
|
|
let doc = read(&bytes)?;
|
|
let fonts = doc
|
|
.get("0")?
|
|
.get("1")?
|
|
.get("0")?
|
|
.list()
|
|
.iter()
|
|
.map(|f| f.get("0").and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::str).unwrap_or_default().to_string())
|
|
.collect();
|
|
let main = doc.get("1")?;
|
|
Some(Texts {
|
|
stories: main.get("1")?.list().to_vec(),
|
|
fonts,
|
|
defaults: main.get("2")?.clone(),
|
|
para_defaults: main.get("3").cloned().unwrap_or(Val::Dict(BTreeMap::new())),
|
|
frames: doc.get("0")?.get("8")?.get("0")?.list().to_vec(),
|
|
})
|
|
}
|
|
|
|
/// Frame `i` of the document, if it is of a kind this reads.
|
|
fn frame(&self, i: usize) -> Option<FrameKind> {
|
|
let f = self.frames.get(i)?.get("0")?;
|
|
let carries = f.get("2")?;
|
|
let segments = || -> Option<Vec<[f64; 8]>> {
|
|
let n = f.get("1")?.get("0")?.nums()?;
|
|
let (chunks, rest) = n.as_chunks::<8>();
|
|
(rest.is_empty() && !chunks.is_empty() && chunks.len() <= MAX_SEGMENTS).then(|| chunks.to_vec())
|
|
};
|
|
match carries.get("0").and_then(Val::num).unwrap_or(0.0) as i64 {
|
|
0 => Some(FrameKind::Point(carries.get("2")?.nums()?.try_into().ok()?)),
|
|
1 => Some(FrameKind::Area(segments()?)),
|
|
2 => {
|
|
let range = carries.get("6").and_then(Val::nums).unwrap_or_default();
|
|
let segments = segments()?;
|
|
let n = segments.len() as f64;
|
|
let start = range.first().copied().unwrap_or(0.0).clamp(0.0, n);
|
|
let end = range.get(1).copied().unwrap_or(n).clamp(start, n);
|
|
Some(FrameKind::OnPath { segments, start, end })
|
|
}
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Story `i`, if its frames are of kinds this reads.
|
|
pub(super) fn story(&self, i: usize) -> Option<Story> {
|
|
let s = self.stories.get(i)?;
|
|
let text = s.get("0")?.get("0")?.str().filter(|t| t.len() <= MAX_STORY)?.to_string();
|
|
let view = s.get("1")?;
|
|
let frames: Vec<FrameKind> = view
|
|
.get("0")?
|
|
.list()
|
|
.iter()
|
|
.take(MAX_FRAMES)
|
|
.map(|f| f.get("0").and_then(Val::num).and_then(|n| self.frame(n as usize)))
|
|
.collect::<Option<_>>()?;
|
|
if frames.is_empty() {
|
|
return None;
|
|
}
|
|
// The first frame's layout, when the file keeps one: where its lines start.
|
|
let (mut anchor, mut lines) = ((0.0, 0.0), vec![]);
|
|
if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) {
|
|
if let Some(a) = f.get("0").and_then(|a| a.get("0")).and_then(Val::nums)
|
|
&& let [x, y, ..] = a.as_slice()
|
|
{
|
|
anchor = (*x, *y);
|
|
}
|
|
let mut found = vec![];
|
|
find_nodes(f, "L", &mut found);
|
|
for l in found {
|
|
let off = l.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]);
|
|
let Some(seg) = l.get("6").map(Val::list).and_then(|c| c.iter().find(|c| c.is_node("S"))) else { continue };
|
|
let so = seg.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]);
|
|
if let (Some(ox), Some(oy), Some(sx)) = (off.first(), off.get(1), so.first()) {
|
|
lines.push((ox + sx, *oy));
|
|
}
|
|
}
|
|
}
|
|
let mut paras = vec![];
|
|
for run in s.get("0")?.get("5").map(|p| p.get("0").map(Val::list).unwrap_or_default()).unwrap_or_default() {
|
|
let n = run.get("1").and_then(Val::num)? as usize;
|
|
let props = run.get("0").and_then(|r| r.get("0")).and_then(|r| r.get("5"));
|
|
paras.push((n, self.para(props)));
|
|
}
|
|
let mut runs = vec![];
|
|
for run in s.get("0")?.get("6").map(|p| p.get("0").map(Val::list).unwrap_or_default()).unwrap_or_default() {
|
|
let n = run.get("1").and_then(Val::num)? as usize;
|
|
let props = run.get("0").and_then(|r| r.get("0")).and_then(|r| r.get("6"));
|
|
runs.push((n, self.style(props)));
|
|
}
|
|
if runs.is_empty() {
|
|
runs.push((text.chars().count(), self.style(None)));
|
|
}
|
|
Some(Story { text, anchor, lines, frames, paras, runs })
|
|
}
|
|
|
|
/// A paragraph's attributes: its own keys, else the document's.
|
|
fn para(&self, p: Option<&Val>) -> Para {
|
|
let pick = |k: &str| p.and_then(|p| p.get(k)).or_else(|| self.para_defaults.get(k));
|
|
let num = |k: &str| pick(k).and_then(Val::num).filter(|v| v.abs() < 1e5).unwrap_or(0.0);
|
|
Para {
|
|
justify: pick("0").and_then(Val::num).map_or(0, |v| v.clamp(0.0, 6.0) as u8),
|
|
first_indent: num("1"),
|
|
left_indent: num("2"),
|
|
right_indent: num("3"),
|
|
space_before: num("4"),
|
|
space_after: num("5"),
|
|
hyphenate: !matches!(pick("9"), Some(Val::Bool(false))),
|
|
}
|
|
}
|
|
|
|
/// A character style: its own keys, else the document's.
|
|
fn style(&self, s: Option<&Val>) -> Style {
|
|
let pick = |k: &str| s.and_then(|s| s.get(k)).or_else(|| self.defaults.get(k));
|
|
let num = |k: &str| pick(k).and_then(Val::num);
|
|
let on = |k: &str| !matches!(pick(k), Some(Val::Bool(false)));
|
|
let font = num("0").and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned();
|
|
let size = num("1").filter(|v| *v > 0.0 && *v < 1e5).unwrap_or(12.0);
|
|
Style {
|
|
font,
|
|
size,
|
|
leading: if on("4") { None } else { num("5").filter(|v| *v > 0.0 && *v < 1e5) },
|
|
tracking: num("8").filter(|v| v.abs() < 1e5).unwrap_or(0.0),
|
|
baseline_shift: num("9").filter(|v| v.abs() < 1e5).unwrap_or(0.0),
|
|
h_scale: num("6").filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0),
|
|
v_scale: num("7").filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0),
|
|
fill: if on("56") { Some(pick("53").and_then(paint).unwrap_or(Color::BLACK)) } else { None },
|
|
stroke: if matches!(pick("57"), Some(Val::Bool(true))) { pick("54").and_then(paint) } else { None },
|
|
stroke_width: num("63").filter(|v| *v >= 0.0 && *v < 1e4).unwrap_or(1.0),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The colour of a paint of the text document: kind 0 grey (`[alpha, level]`, 1 is white), 1 RGB
|
|
/// (`[alpha, r, g, b]`), 2 CMYK (`[alpha, c, m, y, k]`).
|
|
fn paint(p: &Val) -> Option<Color> {
|
|
let p = p.get("0")?;
|
|
let v = p.get("1")?.nums()?;
|
|
let unit = |i: usize| v.get(i).map(|c| c.clamp(0.0, 1.0) as f32);
|
|
match p.get("0")?.num()? as i64 {
|
|
0 => Some(Color::gray(1.0 - unit(1)?)),
|
|
1 => Some(Color::rgb(unit(1)?, unit(2)?, unit(3)?)),
|
|
2 => Some(Color::cmyk(unit(1)?, unit(2)?, unit(3)?, unit(4)?)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn find_node<'a>(v: &'a Val, name: &str) -> Option<&'a Val> {
|
|
let mut out = vec![];
|
|
find_nodes(v, name, &mut out);
|
|
out.into_iter().next()
|
|
}
|
|
|
|
/// The typed nodes called `name` in `v`, in order, outermost first.
|
|
fn find_nodes<'a>(v: &'a Val, name: &str, out: &mut Vec<&'a Val>) {
|
|
match v {
|
|
Val::Dict(d) => {
|
|
if v.is_node(name) {
|
|
out.push(v);
|
|
}
|
|
for x in d.values() {
|
|
find_nodes(x, name, out);
|
|
}
|
|
}
|
|
Val::List(l) => {
|
|
for x in l {
|
|
find_nodes(x, name, out);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// A point of the canvas (y down) in the units of the art (y up), given the centre `template` of
|
|
/// the file's template box.
|
|
fn to_art((x, y): (f64, f64), template: (f64, f64)) -> (f64, f64) {
|
|
(x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y)
|
|
}
|
|
|
|
/// Segments of the canvas as a path of the document.
|
|
fn path_of(segments: &[[f64; 8]], template: (f64, f64), to_doc: Affine) -> PathData {
|
|
let at = |x: f64, y: f64| {
|
|
let (x, y) = to_art((x, y), template);
|
|
to_doc * Point::new(x, y)
|
|
};
|
|
let mut bp = BezPath::new();
|
|
for (i, [x0, y0, x1, y1, x2, y2, x3, y3]) in segments.iter().enumerate() {
|
|
if i == 0 {
|
|
bp.move_to(at(*x0, *y0));
|
|
}
|
|
bp.curve_to(at(*x1, *y1), at(*x2, *y2), at(*x3, *y3));
|
|
}
|
|
let closed = matches!((segments.first(), segments.last()), (Some(f), Some(l)) if (f[0] - l[6]).abs() < 1e-3 && (f[1] - l[7]).abs() < 1e-3);
|
|
if closed {
|
|
bp.close_path();
|
|
}
|
|
PathData::from_bezpath(&bp)
|
|
}
|
|
|
|
/// Where on `segments` the place `t` (segment index and the place in it) is, as a fraction of their
|
|
/// length.
|
|
fn length_fraction(segments: &[[f64; 8]], t: f64) -> f64 {
|
|
let cubic = |s: &[f64; 8]| CubicBez::new((s[0], s[1]), (s[2], s[3]), (s[4], s[5]), (s[6], s[7]));
|
|
let lengths: Vec<f64> = segments.iter().map(|s| cubic(s).arclen(0.01)).collect();
|
|
let total: f64 = lengths.iter().sum();
|
|
if !total.is_finite() || total <= 1e-9 {
|
|
return 0.0;
|
|
}
|
|
let whole = t.floor().max(0.0) as usize;
|
|
let before: f64 = lengths.iter().take(whole).sum();
|
|
let within = match segments.get(whole) {
|
|
Some(s) => cubic(s).subsegment(0.0..t.fract().clamp(0.0, 1.0)).arclen(0.01),
|
|
None => 0.0,
|
|
};
|
|
((before + within) / total).clamp(0.0, 1.0)
|
|
}
|
|
|
|
impl Story {
|
|
/// How many bytes of text the story has.
|
|
pub(super) fn len(&self) -> usize {
|
|
self.text.len()
|
|
}
|
|
|
|
/// The point frame's matrix (the first frame's, when it is one), else the identity.
|
|
fn matrix(&self) -> [f64; 6] {
|
|
match self.frames.first() {
|
|
Some(FrameKind::Point(m)) => *m,
|
|
_ => [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
|
|
}
|
|
}
|
|
|
|
/// The anchor of the story on the file's canvas: `(x, y)` in the units of the art, y up, given
|
|
/// the centre `(tx, ty)` of the file's template box.
|
|
pub(super) fn place(&self, template: (f64, f64)) -> (f64, f64) {
|
|
let [a, b, c, d, e, f] = self.matrix();
|
|
to_art((a * self.anchor.0 + c * self.anchor.1 + e, b * self.anchor.0 + d * self.anchor.1 + f), template)
|
|
}
|
|
|
|
/// Each line's characters and where it starts (in the units of the art, y up): where the page
|
|
/// draws the line when the type is shown.
|
|
pub(super) fn line_starts(&self, template: (f64, f64)) -> Vec<(String, (f64, f64))> {
|
|
let [a, b, c, d, e, f] = self.matrix();
|
|
let text: Vec<&str> = self.text.split(['\r', '\x03']).collect();
|
|
self.lines
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, (lx, ly))| {
|
|
let (x, y) = (self.anchor.0 + lx, self.anchor.1 + ly);
|
|
(text.get(i).copied().unwrap_or_default().to_string(), to_art((a * x + c * y + e, b * x + d * y + f), template))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Roughly where the story's type lies on the document: its frame's box, or round its lines
|
|
/// for point type (what the page draws of it lies there).
|
|
pub(super) fn region(&self, template: (f64, f64), to_doc: Affine) -> Option<Rect> {
|
|
let size = self.runs.iter().map(|(_, s)| s.size).fold(0.0, f64::max);
|
|
let bounds = |segments: &[[f64; 8]]| path_of(segments, template, to_doc).bounds();
|
|
let r = match self.frames.first()? {
|
|
FrameKind::Area(s) => bounds(s)?,
|
|
FrameKind::OnPath { segments, .. } => bounds(segments)?.inflate(size * 1.5, size * 1.5),
|
|
FrameKind::Point(_) => {
|
|
let longest = self.text.split(['\r', '\x03']).map(|l| l.chars().count()).max().unwrap_or(0) as f64;
|
|
let reach = longest * size + size;
|
|
let points: Vec<Point> = self.line_starts(template).into_iter().map(|(_, (x, y))| to_doc * Point::new(x, y)).collect();
|
|
let first = points.first().copied().unwrap_or_else(|| {
|
|
let (x, y) = self.place(template);
|
|
to_doc * Point::new(x, y)
|
|
});
|
|
points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)).inflate(reach, size * 1.5)
|
|
}
|
|
};
|
|
r.is_finite().then_some(r)
|
|
}
|
|
|
|
/// The story as the text object of its frame `frame` (`to_doc` takes the art's units onto the
|
|
/// document), or `None` for type that is mirrored, has no size, or a frame past the first of
|
|
/// threaded type (the first frame holds the whole story).
|
|
pub(super) fn node(&self, id: NodeId, frame: usize, template: (f64, f64), to_doc: Affine) -> Option<Node> {
|
|
let kind = self.frames.get(frame)?;
|
|
let threaded = frame > 0;
|
|
let (scale, xf, kind) = match kind {
|
|
FrameKind::Point(m) => {
|
|
let [a, b, c, d, ..] = *m;
|
|
let scale = (a * d - b * c).abs().sqrt();
|
|
if !(scale.is_finite() && scale > 1e-6 && a * d - b * c > 0.0) || threaded {
|
|
return None;
|
|
}
|
|
let (x, y) = self.place(template);
|
|
let at = to_doc * Point::new(x, y);
|
|
(scale, Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]), TextKind::Point)
|
|
}
|
|
FrameKind::Area(s) => (1.0, Affine::IDENTITY, TextKind::Area { frame: path_of(s, template, to_doc) }),
|
|
FrameKind::OnPath { segments, start, end } => {
|
|
let n = segments.len() as f64;
|
|
let end = (*end < n - 0.02).then(|| length_fraction(segments, *end));
|
|
(1.0, Affine::IDENTITY, TextKind::OnPath { path: path_of(segments, template, to_doc), start: length_fraction(segments, *start), end })
|
|
}
|
|
};
|
|
let text: String = if threaded {
|
|
String::new()
|
|
} else {
|
|
self.text.trim_end_matches('\r').chars().map(|c| if matches!(c, '\r' | '\x03') { '\n' } else { c }).collect()
|
|
};
|
|
if text.trim().is_empty() && !threaded {
|
|
return None;
|
|
}
|
|
let leading = self.line_leading(scale);
|
|
let chars: Vec<char> = text.chars().collect();
|
|
let mut runs = vec![];
|
|
let mut from = 0usize;
|
|
for (n, st) in &self.runs {
|
|
let to = from.saturating_add(*n).min(chars.len());
|
|
let piece: String = chars.get(from..to).unwrap_or_default().iter().collect();
|
|
from = to;
|
|
if piece.is_empty() {
|
|
continue;
|
|
}
|
|
runs.push(TextRun::new(piece, char_style(st, scale, leading)));
|
|
}
|
|
if from < chars.len() {
|
|
let last = self.runs.last().map(|r| &r.1)?;
|
|
runs.push(TextRun::new(chars.get(from..).unwrap_or_default().iter().collect::<String>(), char_style(last, scale, leading)));
|
|
}
|
|
let first = self.runs.first().map(|r| char_style(&r.1, scale, leading))?;
|
|
let mut t = TextObject::point(Point::ZERO, "", first);
|
|
if !runs.is_empty() {
|
|
t.runs = runs;
|
|
}
|
|
let paras: Vec<ParaStyle> = self.paragraph_styles(&text, scale);
|
|
t.para = paras.first().cloned().unwrap_or_default();
|
|
if paras.len() > 1 && paras.iter().any(|p| *p != t.para) {
|
|
t.paras = paras;
|
|
}
|
|
t.kind = kind;
|
|
t.xf = xf;
|
|
Some(Node::new(id, NodeKind::Text(Box::new(t))))
|
|
}
|
|
|
|
/// Area type that the file lays lines out in, in a frame too short for its first line as it is
|
|
/// laid out here: a font that isn't available shows in the fallback font, whose lines may be
|
|
/// taller, and type that doesn't fit its frame isn't drawn. The frame is made taller, from its
|
|
/// top, by what the first line needs. Whether it was.
|
|
pub(super) fn fit_first_line(&self, t: &mut TextObject) -> bool {
|
|
let TextKind::Area { frame } = &t.kind else { return false };
|
|
let db = vectorcraft_text::FontDb::global();
|
|
let Some(b) = frame.bounds().filter(|b| b.height() > 1e-6 && !self.lines.is_empty()) else { return false };
|
|
if !vectorcraft_text::layout(db, t).lines.is_empty() {
|
|
return false;
|
|
}
|
|
// The height of its first line: the line's as point type.
|
|
let point = TextObject { kind: TextKind::Point, ..t.clone() };
|
|
let Some(need) = vectorcraft_text::layout(db, &point).lines.first().map(|l| l.ascent + l.descent) else { return false };
|
|
// (A little more than it, for the rounding of the layout.)
|
|
let by = (need + 0.05) / b.height();
|
|
if !(by.is_finite() && by > 1.0) {
|
|
return false;
|
|
}
|
|
let taller = frame.transformed(Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, by) * Affine::translate((0.0, -b.y0)));
|
|
let short = std::mem::replace(&mut t.kind, TextKind::Area { frame: taller });
|
|
// Something else keeps the line out (a frame narrower than a letter): as the file has it.
|
|
if vectorcraft_text::layout(db, t).lines.is_empty() {
|
|
t.kind = short;
|
|
return false;
|
|
}
|
|
true
|
|
}
|
|
|
|
/// The leading the file's own line positions imply: the distance from one line to the next, less
|
|
/// the space between paragraphs that is part of it (a paragraph's space after and the next one's
|
|
/// space before; a layout's lines are where that space has put them, and the text object adds it
|
|
/// again).
|
|
fn line_leading(&self, scale: f64) -> Option<f64> {
|
|
let para_at = |c: usize| {
|
|
let mut end = 0usize;
|
|
for (n, p) in &self.paras {
|
|
end = end.saturating_add(*n);
|
|
if c < end {
|
|
return Some(p);
|
|
}
|
|
}
|
|
self.paras.last().map(|(_, p)| p)
|
|
};
|
|
let chars: Vec<char> = self.text.chars().collect();
|
|
let breaks: Vec<usize> = chars.iter().enumerate().filter(|(_, c)| matches!(c, '\r' | '\x03')).map(|(i, _)| i).collect();
|
|
// More lines than breaks: area type wraps, and which break ends which line is unknown.
|
|
let wraps = self.lines.len() > breaks.len() + 1;
|
|
self.lines.windows(2).enumerate().find_map(|(i, w)| {
|
|
let pitch = w[1].1 - w[0].1;
|
|
let between = match breaks.get(i) {
|
|
Some(&at) if !wraps && chars.get(at) == Some(&'\r') => {
|
|
para_at(at).map_or(0.0, |p| p.space_after) + para_at(at + 1).map_or(0.0, |p| p.space_before)
|
|
}
|
|
_ => 0.0,
|
|
};
|
|
let l = (pitch - between) * scale;
|
|
(l.is_finite() && l > 0.0).then_some(l)
|
|
})
|
|
}
|
|
|
|
/// The style of each paragraph of `text` (paragraphs split at `\n`).
|
|
fn paragraph_styles(&self, text: &str, scale: f64) -> Vec<ParaStyle> {
|
|
let count = text.split('\n').count();
|
|
let mut out = Vec::with_capacity(count);
|
|
let mut runs = self.paras.iter();
|
|
let mut current = runs.next();
|
|
let mut left = current.map_or(usize::MAX, |r| r.0);
|
|
for p in text.split('\n') {
|
|
let para = current.map(|r| r.1.clone()).unwrap_or_default();
|
|
out.push(para_style(¶, scale));
|
|
// Each paragraph and its ending `\r` count.
|
|
let mut used = p.chars().count() + 1;
|
|
while used > 0 {
|
|
if left > used {
|
|
left -= used;
|
|
used = 0;
|
|
} else {
|
|
used -= left;
|
|
current = runs.next();
|
|
left = current.map_or(usize::MAX, |r| r.0);
|
|
if current.is_none() {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
fn para_style(p: &Para, scale: f64) -> ParaStyle {
|
|
ParaStyle {
|
|
justify: match p.justify {
|
|
1 => Justify::Right,
|
|
2 => Justify::Center,
|
|
3 => Justify::JustifyLeft,
|
|
4 => Justify::JustifyRight,
|
|
5 => Justify::JustifyCenter,
|
|
6 => Justify::JustifyAll,
|
|
_ => Justify::Left,
|
|
},
|
|
left_indent: p.left_indent * scale,
|
|
right_indent: p.right_indent * scale,
|
|
first_line_indent: p.first_indent * scale,
|
|
space_before: p.space_before * scale,
|
|
space_after: p.space_after * scale,
|
|
hyphenate: p.hyphenate,
|
|
..ParaStyle::default()
|
|
}
|
|
}
|
|
|
|
fn char_style(s: &Style, scale: f64, leading: Option<f64>) -> CharStyle {
|
|
let (family, style) = s.font.as_deref().map_or_else(|| (CharStyle::default().font_family, "Regular".to_string()), crate::family_style);
|
|
let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
|
|
CharStyle {
|
|
font_family: family,
|
|
font_style: style,
|
|
size: s.size * scale,
|
|
leading: s.leading.map(|l| l * scale).or(leading),
|
|
tracking: s.tracking,
|
|
baseline_shift: s.baseline_shift * scale,
|
|
h_scale: s.h_scale * 100.0,
|
|
v_scale: s.v_scale * 100.0,
|
|
fill: s.fill.map_or(Paint::None, Paint::solid),
|
|
stroke: s.stroke.map_or(Paint::None, Paint::solid),
|
|
stroke_width: if s.stroke.is_some() { s.stroke_width * scale } else { 0.0 },
|
|
..CharStyle::default()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn values_read_nested_dictionaries_lists_and_utf16_strings() {
|
|
let v = read(b" /1 << /0 [ 1 2.5 -3 ] /2 (\xfe\xff\x00h\x00i) /3 true /99 /Name >> /0 (a\\)b\\n)").unwrap();
|
|
let one = v.get("1").unwrap();
|
|
assert_eq!(one.get("0").unwrap().nums(), Some(vec![1.0, 2.5, -3.0]));
|
|
assert_eq!(one.get("2").unwrap().str(), Some("hi"));
|
|
assert_eq!(one.get("3"), Some(&Val::Bool(true)));
|
|
assert!(one.is_node("Name"));
|
|
assert_eq!(v.get("0").unwrap().str(), Some("a)b\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn damaged_documents_are_not_read() {
|
|
for bad in [&b"/0 << /1"[..], b"/0 [ 1 2", b"/0 (abc", b"/0 nope", b"0 1"] {
|
|
assert!(read(bad).is_none(), "{}", String::from_utf8_lossy(bad));
|
|
}
|
|
let deep = format!("/0 {}", "[".repeat(200));
|
|
assert!(read(deep.as_bytes()).is_none());
|
|
}
|
|
|
|
/// A point-type story of `text` whose lines start at the heights `ys` (the layout's own: where
|
|
/// the paragraph spacing has put them), every paragraph styled `para`.
|
|
fn story(text: &str, ys: &[f64], para: &Para) -> Story {
|
|
let size = 13.0;
|
|
let paras = text.split_inclusive('\r').map(|p| (p.chars().count(), para.clone())).collect();
|
|
Story {
|
|
text: text.to_string(),
|
|
anchor: (0.0, 0.0),
|
|
lines: ys.iter().map(|y| (0.0, *y)).collect(),
|
|
frames: vec![FrameKind::Point([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])],
|
|
paras,
|
|
runs: vec![(
|
|
text.chars().count(),
|
|
Style {
|
|
font: None,
|
|
size,
|
|
leading: None,
|
|
tracking: 0.0,
|
|
baseline_shift: 0.0,
|
|
h_scale: 1.0,
|
|
v_scale: 1.0,
|
|
fill: Some(Color::BLACK),
|
|
stroke: None,
|
|
stroke_width: 1.0,
|
|
},
|
|
)],
|
|
}
|
|
}
|
|
|
|
/// The leading of the text object a story comes in as.
|
|
fn leading_of(story: &Story) -> Option<f64> {
|
|
let node = story.node(NodeId(1), 0, (0.0, 0.0), Affine::IDENTITY)?;
|
|
match &node.kind {
|
|
NodeKind::Text(t) => t.runs.first().and_then(|r| r.style.leading),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn leading_leaves_out_the_space_between_paragraphs_that_the_lines_already_have() {
|
|
// Lines 10.6 apart that are three paragraphs with 5 less after each: auto leading (15.6) less 5.
|
|
let para = Para { space_after: -5.0, ..Para::default() };
|
|
let s = story("one\rtwo\rthree\r", &[0.0, 10.6, 21.2], ¶);
|
|
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
|
|
assert!((leading_of(&s).unwrap() - 15.6).abs() < 1e-9);
|
|
// Space after and space before both count; the scale of the frame carries onto the leading.
|
|
let para = Para { space_after: 4.0, space_before: 2.0, ..Para::default() };
|
|
let s = story("one\rtwo\r", &[0.0, 20.0], ¶);
|
|
assert!((s.line_leading(1.0).unwrap() - 14.0).abs() < 1e-9);
|
|
assert!((s.line_leading(0.5).unwrap() - 7.0).abs() < 1e-9);
|
|
}
|
|
|
|
#[test]
|
|
fn leading_between_lines_of_a_paragraph_is_their_distance() {
|
|
// A line break inside a paragraph has no paragraph spacing between its lines.
|
|
let para = Para { space_after: -5.0, space_before: 3.0, ..Para::default() };
|
|
let s = story("one\x03two\x03three\r", &[0.0, 15.6, 31.2], ¶);
|
|
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
|
|
// Paragraphs without spacing: the distance as it was.
|
|
let s = story("one\rtwo\r", &[0.0, 15.6], &Para::default());
|
|
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
|
|
// Area type whose paragraph wraps: lines and breaks don't pair up, so nothing is taken off.
|
|
let para = Para { space_after: -5.0, ..Para::default() };
|
|
let s = story("one two three\rfour\r", &[0.0, 15.6, 31.2, 41.8], ¶);
|
|
assert!((s.line_leading(1.0).unwrap() - 15.6).abs() < 1e-9);
|
|
// One line has no distance to read.
|
|
assert_eq!(story("one\r", &[0.0], &Para::default()).line_leading(1.0), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_frame_too_short_for_its_first_line_is_made_taller() {
|
|
// A heading in a frame as tall as its size: a line of the font it shows in here is taller.
|
|
let s = story("Heading\r", &[0.0], &Para::default());
|
|
let area = |height: f64| {
|
|
let mut t = TextObject::point(Point::ZERO, "Heading", CharStyle { size: 13.0, ..CharStyle::default() });
|
|
t.kind = TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(Rect::new(10.0, 20.0, 210.0, 20.0 + height)) };
|
|
t
|
|
};
|
|
let height =
|
|
|t: &TextObject| if let TextKind::Area { frame } = &t.kind { frame.bounds().map(|b| (b.x0, b.y0, b.x1, b.height())) } else { None };
|
|
let lines = |t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).lines.len();
|
|
let mut t = area(13.0);
|
|
assert_eq!(lines(&t), 0, "nothing of it shows");
|
|
assert!(s.fit_first_line(&mut t));
|
|
let (x0, y0, x1, h) = height(&t).unwrap();
|
|
assert!((x0, y0, x1) == (10.0, 20.0, 210.0) && h > 13.0 && h < 26.0, "taller from its top: {h}");
|
|
assert_eq!(lines(&t), 1);
|
|
// A frame that holds the line stays as it is; so does one the file lays no line out in
|
|
// (its type is overset in the file too).
|
|
let mut t = area(40.0);
|
|
assert!(!s.fit_first_line(&mut t) && height(&t).unwrap().3 == 40.0);
|
|
let mut t = area(13.0);
|
|
assert!(!story("Heading\r", &[], &Para::default()).fit_first_line(&mut t) && height(&t).unwrap().3 == 13.0);
|
|
}
|
|
}
|