Merge pull request #753 from storytold/feat/637-type-from-text-document
Part of #637: area type, threaded type and type on a path from the text document
This commit is contained in:
commit
71d8881186
File diff suppressed because one or more lines are too long
|
|
@ -228,6 +228,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
// Threaded type read from a file flows through its frames.
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
}
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None, opts.grays);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1691,12 +1691,69 @@ fn text_document_text() -> String {
|
|||
.to_string()
|
||||
}
|
||||
|
||||
/// The text document of area type threaded through two frames and of type on a path, as an
|
||||
/// editing copy keeps it (frames on the canvas, y down).
|
||||
fn frames_document_text() -> String {
|
||||
let area = |x0: f64, x1: f64| {
|
||||
let (y0, y1) = (8201.5, 8221.5);
|
||||
let corners = [(x0, y0), (x0, y1), (x1, y1), (x1, y0), (x0, y0)];
|
||||
let segments: Vec<String> = corners.windows(2).map(|w| format!("{0} {1} {0} {1} {2} {3} {2} {3}", w[0].0, w[0].1, w[1].0, w[1].1)).collect();
|
||||
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 /7 18 >> >> >>", segments.join(" "))
|
||||
};
|
||||
let path = "<< /0 << /0 [ 0 0 ] /1 << /0 [ 8151.5 8171.5 8151.5 8171.5 8191.5 8161.5 8191.5 8171.5 8191.5 8171.5 8211.5 8181.5 8231.5 8171.5 8231.5 8171.5 ] >> /2 << /0 2 /6 [ 0.5 2.0 ] >> >> >>";
|
||||
let story = |text: &str, frames: &str, style: &str| {
|
||||
let n = text.chars().count() - text.matches('\\').count();
|
||||
format!(
|
||||
"<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 2 /1 4 /2 6 >> /6 0 >> >> /1 {n} >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << {style} >> >> >> /1 {n} >> ] >> >> /1 << /0 [ {frames} ] >> >>"
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} {} {path} ] >> >>\n/1 << /1 [ {} {} ] /2 << /1 12.0 >> >>\n",
|
||||
area(8151.5, 8201.5),
|
||||
area(8211.5, 8241.5),
|
||||
story(
|
||||
"A story in two frames\\rand more\\r",
|
||||
"<< /0 0 >> << /0 1 >>",
|
||||
"/1 9.0 /8 50 /53 << /99 /CAITextPaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >>"
|
||||
),
|
||||
story("On a path\\r", "<< /0 2 >>", "/1 8.0"),
|
||||
)
|
||||
}
|
||||
|
||||
/// The text objects of [`frames_document_text`]: story 0 in its two frames, story 1 in its one (a
|
||||
/// text object names its frame among its story's).
|
||||
const FRAME_OBJECTS: &str = "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n1 /FrameIndex ,\n0 /StoryIndex ,\n;\n/AI11Text :\n0 /FrameIndex ,\n1 /StoryIndex ,\n;\n";
|
||||
|
||||
/// The fuzzed fixture, as it is, reads as area type in two threaded frames and type on a path.
|
||||
#[test]
|
||||
fn the_frames_fixture_reads_as_threaded_area_type_and_type_on_a_path() {
|
||||
let l = vectorcraft_engine::cmd::fileio::load("x.eps", &text_eps_of(&frames_document_text(), FRAME_OBJECTS)).unwrap();
|
||||
let mut kinds = vec![];
|
||||
l.doc.walk(|n| {
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
|
||||
kinds.push(match t.kind {
|
||||
vectorcraft_doc::TextKind::Area { .. } => "area",
|
||||
vectorcraft_doc::TextKind::OnPath { .. } => "path",
|
||||
_ => "point",
|
||||
});
|
||||
}
|
||||
});
|
||||
assert_eq!(kinds, ["area", "area", "path"], "{:?}", l.warnings);
|
||||
assert_eq!(l.doc.text_threads.len(), 1);
|
||||
}
|
||||
|
||||
/// An EPS with a hidden text object whose story is in `document`.
|
||||
fn text_eps(document: &str) -> Vec<u8> {
|
||||
text_eps_of(document, "/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n")
|
||||
}
|
||||
|
||||
/// An EPS with the text `objects` on a hidden layer, their stories in `document`.
|
||||
fn text_eps_of(document: &str, objects: &str) -> Vec<u8> {
|
||||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
let editing = format!(
|
||||
"%!PS-Adobe-3.0 \n%%BoundingBox: 0 0 100 100\n%%HiResBoundingBox: 0 0 100 100\n%AI3_Cropmarks: 0 0 100 100\n%AI3_TemplateBox: 50 50 50 50\n\
|
||||
%AI5_BeginLayer\n0 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Spare) Ln\n/AI11Text :\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\nLB\n%AI5_EndLayer--\n\
|
||||
%AI5_BeginLayer\n0 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(Spare) Ln\n{objects}LB\n%AI5_EndLayer--\n\
|
||||
%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n%%Trailer\n",
|
||||
lines.join("\n")
|
||||
);
|
||||
|
|
@ -1714,6 +1771,15 @@ proptest! {
|
|||
survive("mutated EPS text document", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// The same for area type threaded through frames and type on a path, opened as a document
|
||||
/// (its threads flow).
|
||||
#[test]
|
||||
fn eps_text_frames_never_panic(cut in 0usize..2_500, edits in prop::collection::vec((0usize..2_500, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', '[', ']', '<', '>', '\\', 'e', '1', '2'])), 0..12)) {
|
||||
let text = mutate_text(&frames_document_text(), cut, &edits);
|
||||
let bytes = text_eps_of(&text, FRAME_OBJECTS);
|
||||
survive("mutated EPS text frames", || vectorcraft_engine::cmd::fileio::load("x.eps", &bytes).ok().map(|l| l.doc))?;
|
||||
}
|
||||
|
||||
/// An Illustrator EPS whose editing data is damaged or hostile: read as its layers, or as its page.
|
||||
#[test]
|
||||
fn eps_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'k', 'x'])), 0..12)) {
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ use std::io::Read as _;
|
|||
use super::lex::find;
|
||||
use crate::ps;
|
||||
|
||||
pub(crate) use read::slot_of;
|
||||
pub use read::{Structure, read};
|
||||
pub(crate) use read::{slot_frame, slot_of};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
|
|
@ -78,7 +78,12 @@ pub(crate) const TEXT_SLOT: &str = "\u{0}text";
|
|||
|
||||
/// Is `name` that of a text slot, and which story is it of?
|
||||
pub(crate) fn slot_of(name: Option<&str>) -> Option<Option<u32>> {
|
||||
name?.strip_prefix(TEXT_SLOT).map(|rest| rest.parse().ok())
|
||||
name?.strip_prefix(TEXT_SLOT).map(|rest| rest.split(':').next().and_then(|s| s.parse().ok()))
|
||||
}
|
||||
|
||||
/// Which of its story's frames a text slot named `name` is (0 when it doesn't say).
|
||||
pub(crate) fn slot_frame(name: Option<&str>) -> usize {
|
||||
name.and_then(|n| n.strip_prefix(TEXT_SLOT)).and_then(|rest| rest.split_once(':')).and_then(|(_, f)| f.parse().ok()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Read the structure of the editing data `data`.
|
||||
|
|
@ -844,10 +849,11 @@ impl<'a> Reader<'a> {
|
|||
|
||||
/// A text object (`/AI11Text`): an empty group standing for it, named after its story.
|
||||
fn text_slot(&mut self, o: &Obj) -> Result<(), String> {
|
||||
let story = o.nums("StoryIndex").first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
|
||||
let index = |key: &str| o.nums(key).first().copied().filter(|v| (0.0..f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
|
||||
let (story, frame) = (index("StoryIndex"), index("FrameIndex").unwrap_or(0));
|
||||
let gs = self.gs.clone();
|
||||
let mut n = self.new_node(NodeKind::Group { children: vec![], clip: false }, &gs);
|
||||
n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}")));
|
||||
n.name = Some(story.map_or_else(|| TEXT_SLOT.to_string(), |s| format!("{TEXT_SLOT}{s}:{frame}")));
|
||||
let hidden = self.obj_hidden;
|
||||
self.add(n, hidden)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,10 @@
|
|||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use kurbo::{BezPath, CubicBez, ParamCurve, ParamCurveArclen};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaStyle, TextObject, TextRun};
|
||||
use vectorcraft_geom::{Affine, Point};
|
||||
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;
|
||||
|
|
@ -54,13 +55,6 @@ impl Val {
|
|||
}
|
||||
}
|
||||
|
||||
fn at(&self, i: usize) -> Option<&Val> {
|
||||
match self {
|
||||
Val::List(l) => l.get(i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn num(&self) -> Option<f64> {
|
||||
match self {
|
||||
Val::Num(n) if n.is_finite() => Some(*n),
|
||||
|
|
@ -239,12 +233,32 @@ fn read(bytes: &[u8]) -> Option<Val> {
|
|||
}
|
||||
}
|
||||
|
||||
/// How a paragraph's lines sit against its anchor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Align {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
/// 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.
|
||||
|
|
@ -252,13 +266,14 @@ enum Align {
|
|||
pub(super) struct Story {
|
||||
/// The characters, with `\r` ending paragraphs and `\x03` ending lines.
|
||||
text: String,
|
||||
/// The anchor of the layout.
|
||||
/// The anchor of the first frame's layout.
|
||||
anchor: (f64, f64),
|
||||
/// Where each line starts, from the anchor.
|
||||
/// Where each of the first frame's lines starts, from the anchor.
|
||||
lines: Vec<(f64, f64)>,
|
||||
/// Carries the layout onto the canvas.
|
||||
matrix: [f64; 6],
|
||||
align: Align,
|
||||
/// 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)>,
|
||||
}
|
||||
|
|
@ -267,8 +282,14 @@ pub(super) struct Story {
|
|||
struct Style {
|
||||
font: Option<String>,
|
||||
size: f64,
|
||||
fill: Option<[f64; 4]>,
|
||||
stroke: Option<[f64; 4]>,
|
||||
/// `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,
|
||||
}
|
||||
|
||||
|
|
@ -276,8 +297,9 @@ struct Style {
|
|||
pub(super) struct Texts {
|
||||
stories: Vec<Val>,
|
||||
fonts: Vec<String>,
|
||||
/// The character style a story starts from.
|
||||
/// The character and paragraph styles a story starts from.
|
||||
defaults: Val,
|
||||
para_defaults: Val,
|
||||
frames: Vec<Val>,
|
||||
}
|
||||
|
||||
|
|
@ -320,86 +342,135 @@ impl 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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Story `i`, if it is one of point type this reads.
|
||||
/// 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();
|
||||
// The frame the story is in: a matrix and nothing else but its place.
|
||||
let frame = self.frames.get(s.get("1")?.get("0")?.at(0)?.get("0")?.num()? as usize)?.get("0")?;
|
||||
let carries = frame.get("2")?;
|
||||
if carries.get("2").is_none() || carries.as_dict().is_some_and(|d| d.keys().any(|k| k != "2")) {
|
||||
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;
|
||||
}
|
||||
let matrix: [f64; 6] = carries.get("2")?.nums()?.try_into().ok()?;
|
||||
let f = find_node(s.get("1")?.get("2")?, "F")?;
|
||||
let anchor = f.get("0")?.get("0")?.nums()?;
|
||||
let anchor = (*anchor.first()?, *anchor.get(1)?);
|
||||
let mut lines = vec![];
|
||||
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 seg = l.get("6")?.list().iter().find(|c| c.is_node("S"))?;
|
||||
let so = seg.get("0").and_then(|o| o.get("0")).and_then(Val::nums).unwrap_or_else(|| vec![0.0, 0.0]);
|
||||
lines.push((off.first()? + so.first()?, *off.get(1)?));
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
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 align = match s
|
||||
.get("0")
|
||||
.and_then(|t| t.get("5"))
|
||||
.and_then(|p| p.get("0"))
|
||||
.and_then(|p| p.at(0))
|
||||
.and_then(|p| p.get("0"))
|
||||
.and_then(|p| p.get("0"))
|
||||
.and_then(|p| p.get("5"))
|
||||
.and_then(|p| p.get("0"))
|
||||
.and_then(Val::num)
|
||||
{
|
||||
Some(1.0) => Align::Right,
|
||||
Some(2.0) => Align::Center,
|
||||
_ => Align::Left,
|
||||
};
|
||||
let mut runs = vec![];
|
||||
for run in s.get("0")?.get("6")?.get("0")?.list() {
|
||||
let n = run.get("1")?.num()? as usize;
|
||||
let style = run.get("0")?.get("0")?.get("6")?;
|
||||
runs.push((n, self.style(style)));
|
||||
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))),
|
||||
}
|
||||
Some(Story { text, anchor, lines, matrix, align, runs })
|
||||
}
|
||||
|
||||
/// A character style: its own keys, else the document's.
|
||||
fn style(&self, s: &Val) -> Style {
|
||||
let pick = |k: &str| s.get(k).or_else(|| self.defaults.get(k));
|
||||
let paint = |k: &str| -> Option<[f64; 4]> {
|
||||
let p = pick(k)?.get("0")?;
|
||||
// Kind 2 is CMYK: an opacity, then the four inks.
|
||||
(p.get("0")?.num()? == 2.0).then(|| p.get("1")?.nums()).flatten().and_then(|v| Some([*v.get(1)?, *v.get(2)?, *v.get(3)?, *v.get(4)?]))
|
||||
};
|
||||
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 = pick("0").and_then(Val::num).and_then(|i| self.fonts.get(i as usize)).filter(|f| !f.is_empty()).cloned();
|
||||
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: pick("1").and_then(Val::num).filter(|v| *v > 0.0).unwrap_or(12.0),
|
||||
fill: if on("56") { Some(paint("53").unwrap_or([0.0, 0.0, 0.0, 1.0])) } else { None },
|
||||
stroke: if matches!(pick("57"), Some(Val::Bool(true))) { paint("54") } else { None },
|
||||
stroke_width: pick("63").and_then(Val::num).filter(|v| *v >= 0.0).unwrap_or(1.0),
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Val {
|
||||
fn as_dict(&self) -> Option<&BTreeMap<String, Val>> {
|
||||
match self {
|
||||
Val::Dict(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -429,57 +500,145 @@ fn find_nodes<'a>(v: &'a Val, name: &str, out: &mut Vec<&'a Val>) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Where a story's first line starts: on the canvas of the app (what [`Story::place`] gives).
|
||||
/// 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;
|
||||
let (x, y) = (a * self.anchor.0 + c * self.anchor.1 + e, b * self.anchor.0 + d * self.anchor.1 + f);
|
||||
(x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y)
|
||||
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 [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);
|
||||
let (x, y) = (a * x + c * y + e, b * x + d * y + f);
|
||||
(text.get(i).copied().unwrap_or_default().to_string(), (x - CANVAS_CENTRE + template.0, CANVAS_CENTRE + template.1 - y))
|
||||
(text.get(i).copied().unwrap_or_default().to_string(), to_art((a * x + c * y + e, b * x + d * y + f), template))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The story as a text object whose anchor is at `at` on the document (`to_doc` takes the art's
|
||||
/// units onto it), or `None` for type that is mirrored or has no size.
|
||||
pub(super) fn node(&self, id: NodeId, template: (f64, f64), to_doc: Affine) -> Option<Node> {
|
||||
let [a, b, c, d, ..] = self.matrix;
|
||||
let scale = (a * d - b * c).abs().sqrt();
|
||||
if !(scale.is_finite() && scale > 1e-6 && a * d - b * c > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = self.place(template);
|
||||
let at = to_doc * Point::new(x, y);
|
||||
let text: String = self.text.trim_end_matches('\r').chars().map(|c| if matches!(c, '\r' | '\x03') { '\n' } else { c }).collect();
|
||||
if text.trim().is_empty() {
|
||||
/// 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.lines.windows(2).map(|w| (w[1].1 - w[0].1) * scale).find(|l| l.is_finite() && *l > 0.0);
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut runs = vec![];
|
||||
let mut from = 0usize;
|
||||
let styles: Vec<&(usize, Style)> = self.runs.iter().collect();
|
||||
for (n, st) in styles {
|
||||
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;
|
||||
|
|
@ -492,35 +651,86 @@ impl Story {
|
|||
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)));
|
||||
}
|
||||
if runs.is_empty() {
|
||||
return None;
|
||||
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 mut t = TextObject::point(Point::ZERO, "", runs.first()?.style.clone());
|
||||
t.runs = runs;
|
||||
t.para = ParaStyle {
|
||||
justify: match self.align {
|
||||
Align::Left => Justify::Left,
|
||||
Align::Center => Justify::Center,
|
||||
Align::Right => Justify::Right,
|
||||
},
|
||||
..ParaStyle::default()
|
||||
};
|
||||
t.xf = Affine::new([a / scale, b / scale, c / scale, d / scale, at.x, at.y]);
|
||||
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))))
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let ink = |c: [f64; 4]| Paint::solid(Color::cmyk(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32));
|
||||
CharStyle {
|
||||
font_family: family,
|
||||
font_style: style,
|
||||
size: s.size * scale,
|
||||
leading,
|
||||
fill: s.fill.map_or(Paint::None, ink),
|
||||
stroke: s.stroke.map_or(Paint::None, ink),
|
||||
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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ fn few(names: BTreeSet<String>) -> String {
|
|||
/// A text slot of the layers.
|
||||
struct SlotInfo {
|
||||
story: Option<u32>,
|
||||
/// Which of the story's frames it is.
|
||||
frame: usize,
|
||||
/// It, its layer and the objects round it show.
|
||||
shown: bool,
|
||||
}
|
||||
|
|
@ -78,7 +80,7 @@ fn collect_slots(nodes: &[Arc<Node>], shown: bool, out: &mut Vec<SlotInfo>) {
|
|||
for n in nodes {
|
||||
let shown = shown && n.visible;
|
||||
if let Some(story) = slot_of(n.name.as_deref()) {
|
||||
out.push(SlotInfo { story, shown });
|
||||
out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown });
|
||||
} else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) {
|
||||
collect_slots(c, shown, out);
|
||||
}
|
||||
|
|
@ -182,8 +184,10 @@ fn assign(objects: &[Vec<Arc<Node>>], slots: usize) -> Vec<Vec<&[Arc<Node>]>> {
|
|||
enum Content {
|
||||
/// Text objects (with the strokes round them) of the page.
|
||||
Page(Vec<Arc<Node>>),
|
||||
/// A text object made from the file's text document.
|
||||
Made(Box<Node>),
|
||||
/// A text object made from the file's text document, where it lies on the document, whether
|
||||
/// the page has type where it is, and the page's type of no text object that is nearest it
|
||||
/// (kept after it).
|
||||
Made(Box<Node>, Option<Rect>, bool, Vec<Arc<Node>>),
|
||||
/// Nothing the file lets this read.
|
||||
Empty,
|
||||
/// Its type is on the page with another text object's; there is nothing to put here.
|
||||
|
|
@ -199,13 +203,15 @@ fn fill_slots(
|
|||
content: &mut std::vec::IntoIter<Content>,
|
||||
names: &std::collections::HashMap<NodeId, String>,
|
||||
empty: &mut usize,
|
||||
made: &mut Vec<(u32, usize, NodeId)>,
|
||||
) {
|
||||
let mut i = 0;
|
||||
while i < nodes.len() {
|
||||
let Some(n) = nodes.get(i) else { break };
|
||||
if slot_of(n.name.as_deref()).is_some() {
|
||||
if let Some(story) = slot_of(n.name.as_deref()) {
|
||||
let visible = n.visible;
|
||||
let name = names.get(&n.id);
|
||||
let frame = ai::slot_frame(n.name.as_deref());
|
||||
let replacement = match content.next() {
|
||||
Some(Content::Page(parts)) if !parts.is_empty() => {
|
||||
// The page's type (and the strokes round it) take the slot's place as they are.
|
||||
|
|
@ -227,11 +233,20 @@ fn fill_slots(
|
|||
i += n;
|
||||
continue;
|
||||
}
|
||||
Some(Content::Made(mut text)) => {
|
||||
Some(Content::Made(mut text, _, seen, extra)) => {
|
||||
text.id = doc.alloc_id();
|
||||
if let Some(story) = story {
|
||||
made.push((story, frame, text.id));
|
||||
}
|
||||
text.visible = visible;
|
||||
text.name = Some(format!("{MADE}{}", name.map_or("", String::as_str)));
|
||||
Some(*text)
|
||||
// Type the page shows is kept; other made type is weighed against the page.
|
||||
text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) };
|
||||
let mut parts = vec![Arc::new(*text)];
|
||||
parts.extend(extra.iter().map(|p| reid(doc, p)));
|
||||
let n = parts.len();
|
||||
nodes.splice(i..=i, parts);
|
||||
i += n;
|
||||
continue;
|
||||
}
|
||||
Some(Content::Merged) => {
|
||||
nodes.remove(i);
|
||||
|
|
@ -256,7 +271,7 @@ fn fill_slots(
|
|||
&& !matches!(n.kind, NodeKind::Compound { .. })
|
||||
&& let Some(c) = nodes.get_mut(i).map(Arc::make_mut).and_then(Node::children_mut)
|
||||
{
|
||||
fill_slots(doc, c, content, names, empty);
|
||||
fill_slots(doc, c, content, names, empty, made);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
|
@ -399,6 +414,14 @@ fn join_pieces(parts: &mut Vec<Arc<Node>>, lines: &[(String, Point)]) {
|
|||
});
|
||||
}
|
||||
|
||||
/// Do the regions of made type cover most of `object`?
|
||||
fn covered(object: &[Arc<Node>], content: &[Content]) -> bool {
|
||||
let Some(r) = extent(object) else { return false };
|
||||
let inside: f64 =
|
||||
content.iter().filter_map(|c| if let Content::Made(_, Some(region), ..) = c { Some(region.intersect(r).area()) } else { None }).sum();
|
||||
inside >= 0.5 * r.area().max(1e-9)
|
||||
}
|
||||
|
||||
/// The box round `parts`.
|
||||
fn extent(parts: &[Arc<Node>]) -> Option<Rect> {
|
||||
parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))
|
||||
|
|
@ -440,19 +463,46 @@ fn plan(
|
|||
*o = Some(k);
|
||||
}
|
||||
}
|
||||
// The file's text document is trusted where it agrees with the page about something, or where the
|
||||
// page has no type to disagree with.
|
||||
let trusted = owner.iter().any(Option::is_some) || shown.is_empty();
|
||||
let readable = |k: usize| trusted && lines.get(k).is_some_and(Option::is_some);
|
||||
// Type the text document makes, shown or not: the page's pieces of it aren't needed.
|
||||
let mut content: Vec<Content> = infos.iter().map(|_| Content::Empty).collect();
|
||||
if let Some(t) = template {
|
||||
for (k, info) in infos.iter().enumerate() {
|
||||
let Some(Some(story)) = stories.get(k) else { continue };
|
||||
let Some(node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue };
|
||||
let region = story.region(t, to_doc);
|
||||
if let Some(c) = content.get_mut(k) {
|
||||
*c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The file's text document is trusted where it agrees with the page about something (its lines
|
||||
// start where the page's type does, or the page has type where it lays type out), or where the
|
||||
// page has no type to disagree with.
|
||||
let in_region = |object: &[Arc<Node>]| covered(object, &content);
|
||||
let trusted = owner.iter().any(Option::is_some) || shown.is_empty() || shown.iter().any(|o| in_region(o));
|
||||
if !trusted {
|
||||
content.iter_mut().for_each(|c| *c = Content::Empty);
|
||||
}
|
||||
let mut spare: Vec<Vec<Arc<Node>>> = vec![];
|
||||
for (j, object) in shown.iter().enumerate() {
|
||||
match owner.get(j).copied().flatten().filter(|_| trusted) {
|
||||
Some(k) => match content.get_mut(k) {
|
||||
Some(Content::Made(_, _, seen, _)) => *seen = true,
|
||||
Some(Content::Page(p)) => p.extend(object.iter().cloned()),
|
||||
Some(c) => *c = Content::Page(object.clone()),
|
||||
None => {}
|
||||
},
|
||||
// What lies where made type is, is that type as the page draws it.
|
||||
None if covered(object, &content) => {
|
||||
let r = extent(object).unwrap_or(Rect::ZERO);
|
||||
for c in &mut content {
|
||||
if let Content::Made(_, Some(region), seen, _) = c
|
||||
&& !region.intersect(r).is_zero_area()
|
||||
{
|
||||
*seen = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
None => spare.push(object.clone()),
|
||||
}
|
||||
}
|
||||
|
|
@ -461,23 +511,9 @@ fn plan(
|
|||
join_pieces(parts, l);
|
||||
}
|
||||
}
|
||||
// Type of the text document where the page has none: hidden type, and shown type off the page.
|
||||
for (k, c) in content.iter_mut().enumerate() {
|
||||
if matches!(c, Content::Empty)
|
||||
&& readable(k)
|
||||
&& let (Some(Some(story)), Some(t)) = (stories.get(k), template)
|
||||
&& let Some(node) = story.node(NodeId(0), t, to_doc)
|
||||
{
|
||||
*c = Content::Made(Box::new(node));
|
||||
}
|
||||
}
|
||||
// What is left of the page goes by painting order into the shown slots the file can't say.
|
||||
let loose: Vec<usize> = infos
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty)) && !readable(*k))
|
||||
.map(|(k, _)| k)
|
||||
.collect();
|
||||
let loose: Vec<usize> =
|
||||
infos.iter().enumerate().filter(|(k, i)| i.shown && matches!(content.get(*k), Some(Content::Empty))).map(|(k, _)| k).collect();
|
||||
if loose.is_empty() {
|
||||
// Type of the page that no text object says it owns goes with the nearest text object that has
|
||||
// type of the page; with none, it is left out.
|
||||
|
|
@ -485,6 +521,7 @@ fn plan(
|
|||
for object in &spare {
|
||||
let (centre, near) = (extent(object).map(|r| r.center()), |c: &Content| match c {
|
||||
Content::Page(parts) => extent(parts).map(|r| r.center()),
|
||||
Content::Made(_, region, ..) => region.map(|r| r.center()),
|
||||
_ => None,
|
||||
});
|
||||
let nearest = content
|
||||
|
|
@ -492,7 +529,7 @@ fn plan(
|
|||
.filter_map(|c| near(c).zip(centre).map(|(p, q)| ((p.x - q.x).hypot(p.y - q.y), c)))
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0));
|
||||
match nearest {
|
||||
Some((_, Content::Page(parts))) => parts.extend(object.iter().cloned()),
|
||||
Some((_, Content::Page(parts) | Content::Made(_, _, _, parts))) => parts.extend(object.iter().cloned()),
|
||||
_ => left_out = true,
|
||||
}
|
||||
}
|
||||
|
|
@ -658,7 +695,7 @@ fn compared(x: &vectorcraft_render::Rendered, y: &vectorcraft_render::Rendered,
|
|||
let differs: Vec<bool> = x.pixels.as_chunks::<4>().0.iter().zip(y.pixels.as_chunks::<4>().0).map(|(p, q)| differ(p, q)).collect();
|
||||
let (w, h) = (x.width as usize, x.height as usize);
|
||||
let at = |i: usize, j: usize| j.checked_mul(w).and_then(|row| row.checked_add(i)).and_then(|k| differs.get(k)).copied().unwrap_or(false);
|
||||
let typed = covered(areas, w, h);
|
||||
let typed = in_areas(areas, w, h);
|
||||
// A pixel on the region's edge has neighbours outside it, which don't count as differing.
|
||||
let solid = (1..h.saturating_sub(1))
|
||||
.flat_map(|j| (1..w.saturating_sub(1)).map(move |i| (i, j)))
|
||||
|
|
@ -685,7 +722,7 @@ fn type_areas(nodes: &[Arc<Node>], region: Rect, scale: f64, out: &mut Vec<Rect>
|
|||
}
|
||||
|
||||
/// Which of `w` × `h` pixels (row by row) lie in one of `areas`.
|
||||
fn covered(areas: &[Rect], w: usize, h: usize) -> Vec<bool> {
|
||||
fn in_areas(areas: &[Rect], w: usize, h: usize) -> Vec<bool> {
|
||||
// Each area adds one inside it: corners marked, then summed along the rows and down the columns.
|
||||
let stride = w + 1;
|
||||
let mut count = vec![0i64; stride * (h + 1)];
|
||||
|
|
@ -814,8 +851,16 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
|
|||
let content = plan(&infos, &stories, template, to_doc, [&shown, &hidden], &mut notes);
|
||||
let mut empty = 0;
|
||||
let mut layers = std::mem::take(&mut doc.layers);
|
||||
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty);
|
||||
let mut made = vec![];
|
||||
fill_slots(&mut doc, &mut layers, &mut content.into_iter(), &slot_names, &mut empty, &mut made);
|
||||
doc.layers = layers;
|
||||
// A story in several frames is threaded type: its frames in order (the first holds the story).
|
||||
made.sort();
|
||||
for thread in made.chunk_by(|a, b| a.0 == b.0) {
|
||||
if thread.len() > 1 {
|
||||
doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect());
|
||||
}
|
||||
}
|
||||
if empty > 0 {
|
||||
notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,9 +360,9 @@ fn type_on_a_hidden_layer_comes_from_the_text_document() {
|
|||
|
||||
#[test]
|
||||
fn type_with_a_frame_it_cant_read_is_left_out_with_a_note() {
|
||||
// A story whose frame says more than a matrix (a text area, say) isn't guessed at.
|
||||
// A story whose frame is of a kind this doesn't know isn't guessed at.
|
||||
let mut doc = text_document(&[("Area\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]);
|
||||
doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>");
|
||||
doc = doc.replace("/2 << /2 [ 1 0 0 1 0 0 ] >>", "/2 << /0 9 /2 [ 1 0 0 1 0 0 ] >>");
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &text_object(0))), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
assert!(text_of(&r.document.layers[1]).is_empty());
|
||||
|
|
@ -527,11 +527,11 @@ fn an_ai_file_without_its_pdf_part_reads_its_layers_and_makes_its_type() {
|
|||
// Point type from the text document, shown or not; a story it can't read is left out, with a
|
||||
// note that is a loss.
|
||||
let mut doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0), ("Area\r", 0, (8200.0, 8160.0), (0.0, 0.0), 0.0)]);
|
||||
// Story 1 in frame 1, a frame that says more than a matrix.
|
||||
// Story 1 in frame 1, a frame of a kind this doesn't know.
|
||||
let at = doc.rfind("/1 << /0 [ << /0 0 >> ]").unwrap();
|
||||
doc.replace_range(at..at + 23, "/1 << /0 [ << /0 1 >> ]");
|
||||
let at = doc.rfind("/2 << /2 [ 1 0 0 1 0 0 ] >>").unwrap();
|
||||
doc.replace_range(at..at + 27, "/2 << /2 [ 1 0 0 1 0 0 ] /7 1 >>");
|
||||
doc.replace_range(at..at + 27, "/2 << /0 9 /2 [ 1 0 0 1 0 0 ] >>");
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Words", true, &(text_object(0) + &text_object(1)))), &doc);
|
||||
let (d, notes) = crate::ai_alone(&private(&art)).unwrap();
|
||||
assert_eq!(names(&d), ["Back", "Words"], "{notes:?}");
|
||||
|
|
@ -606,3 +606,119 @@ fn type_asked_for_as_outlines_leaves_a_file_whose_type_shows_as_its_pdf_part() {
|
|||
let (d, w) = crate::layered_ai(&private(&hidden), pdf_part(10.0, 86.0), vec![], true);
|
||||
assert_eq!(names(&d), ["Art", "Notes"], "{w:?}");
|
||||
}
|
||||
|
||||
/// A point of the art (y up) on the canvas of a file whose template box is centred at (50, 50).
|
||||
fn canvas(x: f64, y: f64) -> (f64, f64) {
|
||||
(x + 8141.5, 8241.5 - y)
|
||||
}
|
||||
|
||||
/// The segments (four points each) of the rectangle `x0 y0 x1 y1` of the art, on the canvas.
|
||||
fn canvas_rect(x0: f64, y0: f64, x1: f64, y1: f64) -> String {
|
||||
let corners = [canvas(x0, y1), canvas(x0, y0), canvas(x1, y0), canvas(x1, y1), canvas(x0, y1)];
|
||||
corners
|
||||
.windows(2)
|
||||
.map(|w| format!("{} {} {} {} {} {} {} {}", w[0].0, w[0].1, w[0].0, w[0].1, w[1].0, w[1].1, w[1].0, w[1].1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// A story of `text` in `frames`, one paragraph (`para`: its keys) and one style (`style`).
|
||||
fn story(text: &str, frames: &[usize], para: &str, style: &str) -> String {
|
||||
let n = text.chars().count();
|
||||
let frames: String = frames.iter().map(|f| format!("<< /0 {f} >> ")).collect();
|
||||
format!(
|
||||
"<< /0 << /0 ({text}) /5 << /0 [ << /0 << /0 << /0 () /5 << {para} >> /6 0 >> >> /1 {n} >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << {style} >> >> >> /1 {n} >> ] >> >> /1 << /0 [ {frames}] >> >>"
|
||||
)
|
||||
}
|
||||
|
||||
/// A text document of `frames` and `stories` (written by [`story`]).
|
||||
fn document_of(frames: &[String], stories: &[String]) -> String {
|
||||
format!(
|
||||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} ] >> >>\n/1 << /1 [ {} ] /2 << /1 12.0 >> >>\n",
|
||||
frames.join(" "),
|
||||
stories.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
/// A text object of frame `frame` of story `story`.
|
||||
fn frame_object(story: u32, frame: u32) -> String {
|
||||
format!("/AI11Text :\n0 /FreeUndo ,\n{frame} /FrameIndex ,\n{story} /StoryIndex ,\n;\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_and_type_on_a_path_come_from_the_text_document() {
|
||||
let area = format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 /7 18 /8 18 >> >> >>", canvas_rect(10.0, 20.0, 60.0, 40.0));
|
||||
// An open path of two segments, the type from halfway along the first to the end.
|
||||
let (a, b, c) = (canvas(10.0, 70.0), canvas(30.0, 70.0), canvas(50.0, 70.0));
|
||||
let path = format!(
|
||||
"<< /0 << /0 [ 0 0 ] /1 << /0 [ {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} {} ] >> /2 << /0 2 /6 [ 0.5 2.0 ] >> >> >>",
|
||||
a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1, b.0, b.1, b.0, b.1, c.0, c.1, c.0, c.1
|
||||
);
|
||||
let red = "/1 9.0 /8 50 /6 1.5 /53 << /99 /CAITextPaint /0 << /0 1 /1 [ 1.0 1.0 0.0 0.0 ] >> >>";
|
||||
let doc = document_of(
|
||||
&[area, path],
|
||||
&[story("Area type\\rSecond\\r", &[0], "/0 2 /1 4 /2 6 /4 3", red), story("On a path\\r", &[1], "/0 0", "/1 8.0")],
|
||||
);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(text_object(0) + &text_object(1)))), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.len(), 2, "{:?}", r.warnings);
|
||||
let (area, path) = (texts[0], texts[1]);
|
||||
assert_eq!(area.plain_text(), "Area type\nSecond");
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &area.kind else { panic!("area type: {:?}", area.kind) };
|
||||
// Art y 20..40 is 60..80 down the 100 pt page.
|
||||
let b = frame.bounds().unwrap();
|
||||
assert!((b.x0 - 10.0).abs() < 1e-6 && (b.y0 - 60.0).abs() < 1e-6 && (b.x1 - 60.0).abs() < 1e-6 && (b.y1 - 80.0).abs() < 1e-6, "{b:?}");
|
||||
assert_eq!(area.para.justify, vectorcraft_doc::Justify::Center);
|
||||
assert_eq!((area.para.first_line_indent, area.para.left_indent, area.para.space_before), (4.0, 6.0, 3.0));
|
||||
let style = &area.runs[0].style;
|
||||
assert_eq!((style.size, style.tracking, style.h_scale), (9.0, 50.0, 150.0));
|
||||
assert_eq!(style.fill.color(), Some(vectorcraft_color::Color::rgb(1.0, 0.0, 0.0)));
|
||||
let vectorcraft_doc::TextKind::OnPath { path: p, start, end } = &path.kind else { panic!("type on a path: {:?}", path.kind) };
|
||||
assert!((start - 0.25).abs() < 1e-3 && end.is_none(), "{start} {end:?}");
|
||||
assert_eq!(p.bounds().unwrap().width(), 40.0);
|
||||
assert_eq!(path.runs[0].style.size, 8.0);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("couldn't be read")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threaded_area_type_flows_through_its_frames() {
|
||||
let frames = [
|
||||
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(10.0, 60.0, 40.0, 80.0)),
|
||||
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 60.0, 80.0, 80.0)),
|
||||
];
|
||||
let doc = document_of(&frames, &[story("A story in two frames\\r", &[0, 1], "/0 0", "/1 10.0")]);
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, &(frame_object(0, 0) + &frame_object(0, 1)))), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.len(), 2, "{:?}", r.warnings);
|
||||
assert_eq!(texts[0].plain_text(), "A story in two frames");
|
||||
assert_eq!(texts[1].plain_text(), "");
|
||||
let ids: Vec<_> = r.document.layers[1].children().unwrap().iter().map(|n| n.id).collect();
|
||||
assert_eq!(r.document.text_threads, [ids]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shown_type_the_page_draws_in_pieces_comes_whole_from_the_text_document() {
|
||||
// Type on a path the page draws letter by letter: the letters lie on the path, and only the
|
||||
// text document's type object comes in.
|
||||
let (a, b) = (canvas(10.0, 50.0), canvas(90.0, 50.0));
|
||||
let path = format!(
|
||||
"<< /0 << /0 [ 0 0 ] /1 << /0 [ {} {} {} {} {} {} {} {} ] >> /2 << /0 2 /6 [ 0 0.99 ] >> >> >>",
|
||||
a.0, a.1, a.0, a.1, b.0, b.1, b.0, b.1
|
||||
);
|
||||
let doc = document_of(&[path], &[story("Letters\\r", &[0], "/0 0", "/1 12.0")]);
|
||||
let art = native_with_text(&layer("Art", true, &text_object(0)), &doc);
|
||||
let letters: String = "Letters".chars().enumerate().map(|(i, c)| format!("{} 50 moveto ({c}) show ", 12 + 7 * i)).collect();
|
||||
let page = format!("0 0 0 1 setcmykcolor /Helvetica findfont 12 scalefont setfont {letters}");
|
||||
let r = import(&eps(&page, &art)).unwrap();
|
||||
let mut all = vec![];
|
||||
r.document.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
all.push(t.plain_text());
|
||||
}
|
||||
});
|
||||
assert_eq!(all, ["Letters"], "{:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("page's type")), "{:?}", r.warnings);
|
||||
}
|
||||
|
|
|
|||
14
docs/mcp.md
14
docs/mcp.md
|
|
@ -412,8 +412,11 @@ An Illustrator EPS (version 9 on) or `.ai` file opens from the editing data it c
|
|||
colour), groups as they were nested, compound paths, clipping groups, object names, hidden and locked objects, fills and
|
||||
strokes (spot colours as spot swatches), linear and radial gradients, opacity, blend modes, isolation and knockout,
|
||||
embedded images with their alpha channel, guides, and every artboard where it is. An object with several fills or
|
||||
strokes, effects or a brush comes in as its drawn look (a group named after it). Type that shows is the page's, in its
|
||||
text object's place; hidden point type is made from the file's text document. A file whose editing data has symbols,
|
||||
strokes, effects or a brush comes in as its drawn look (a group named after it). Type is made from the file's text
|
||||
document, whole and editable: point type, area type (a story in several frames as threaded type) and type on a path,
|
||||
with its fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment,
|
||||
indents and paragraph spacing. Where the text document can't say, type that shows is the page's, in its text object's
|
||||
place. A file whose editing data has symbols,
|
||||
pattern fills, placed files or anything else the reader doesn't read on a layer that shows, or whose layers look
|
||||
different from its page, opens as before: an EPS as its printed page, a `.ai` file from its PDF content (where plain
|
||||
groups open ungrouped), with a warning saying why. Layers look different when they draw an object the page doesn't (or
|
||||
|
|
@ -449,9 +452,10 @@ CMYK too.
|
|||
|
||||
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) open through the EPS
|
||||
reader (see EPS and PostScript import). An `.ai` saved without PDF compatibility (its PDF part is only a placeholder
|
||||
page) opens from its editing data alone: its type is made from the file's text document where it can be (point type), and
|
||||
what can't be is left out with a warning; so is non-native art (the content of a placed PDF, which Illustrator shows but
|
||||
doesn't edit and keeps as a PDF inside the editing data); without editing data it says it can't be opened.
|
||||
page) opens from its editing data alone: its type is made from the file's text document where it can be (point type,
|
||||
area type and type on a path), and what can't be is left out with a warning; so is non-native art (the content of a
|
||||
placed PDF, which Illustrator shows but doesn't edit and keeps as a PDF inside the editing data); without editing data
|
||||
it says it can't be opened.
|
||||
|
||||
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
|
||||
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
|
||||
|
|
|
|||
Loading…
Reference in New Issue