Merge pull request #1208 from p0lyg0n/fix/ai-copied-type-frames
.ai import: copied area type and type on a path keep their own place
This commit is contained in:
commit
be55c5457f
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,128 @@
|
|||
//! Where each text object of an editing copy is framed: the `/Art` path an `/AI11Text` object
|
||||
//! carries (its area type's frame, or the path type on a path runs along), by the story and frame
|
||||
//! the object shows. The text document's frame of a copied object keeps its original's path, so
|
||||
//! read alone it puts every copy on the original; the object's own `/Art` path is where the file
|
||||
//! places it, in the art space's path operators.
|
||||
//!
|
||||
//! An `/AI11Text` object is written as `/AI11Text :` … `n /FrameIndex ,` `n /StoryIndex ,`
|
||||
//! `/Art :` (its path) `;` … `/ConfiningPath ,` (point type has no `/Art`), its lines plain or each
|
||||
//! led by `%_`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use kurbo::{BezPath, Point};
|
||||
|
||||
use super::find;
|
||||
use super::lex::number;
|
||||
|
||||
/// The most text objects read.
|
||||
const MAX_OBJECTS: usize = 100_000;
|
||||
/// How far past `/AI11Text :` an object's `/Art` path may end (bytes).
|
||||
const MAX_OBJECT: usize = 256 << 10;
|
||||
const OBJECT: &[u8] = b"/AI11Text :";
|
||||
|
||||
/// The `/Art` path of each text object of `data` (an editing copy's document text), in art space
|
||||
/// (y up), by its story and frame. Objects without a readable path are left out.
|
||||
pub(crate) fn text_frames(data: &[u8]) -> HashMap<(u32, usize), BezPath> {
|
||||
let mut out = HashMap::new();
|
||||
let mut at = 0;
|
||||
while let Some(i) = data.get(at..).and_then(|d| find(d, OBJECT)) {
|
||||
let start = at + i;
|
||||
at = start + OBJECT.len();
|
||||
if out.len() >= MAX_OBJECTS {
|
||||
break;
|
||||
}
|
||||
let Some(seg) = data.get(start..(start + MAX_OBJECT).min(data.len())) else { break };
|
||||
// The object ends where the next one starts.
|
||||
let seg = seg.get(OBJECT.len()..).and_then(|rest| find(rest, OBJECT)).and_then(|j| seg.get(..j + OBJECT.len())).unwrap_or(seg);
|
||||
let (Some(art), Some(end)) = (find(seg, b"/Art :"), find(seg, b"/ConfiningPath")) else { continue };
|
||||
if end < art {
|
||||
continue;
|
||||
}
|
||||
let Some(head) = seg.get(..art) else { continue };
|
||||
let (Some(story), frame) = (number_before(head, b"/StoryIndex"), number_before(head, b"/FrameIndex").unwrap_or(0.0)) else { continue };
|
||||
if !(0.0..f64::from(u32::MAX)).contains(&story) || !(0.0..1e6).contains(&frame) {
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = seg.get(art + 6..end).and_then(path) {
|
||||
out.entry((story as u32, frame as usize)).or_insert(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The words of `text`, without the `%_` that leads lines inside dictionaries.
|
||||
fn words(text: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
text.split(|b| b.is_ascii_whitespace()).map(|w| w.strip_prefix(b"%_").unwrap_or(w)).filter(|w| !w.is_empty())
|
||||
}
|
||||
|
||||
/// The number written just before `key` (`49 /StoryIndex`) in `head`.
|
||||
fn number_before(head: &[u8], key: &[u8]) -> Option<f64> {
|
||||
let k = find(head, key)?;
|
||||
number(words(head.get(..k)?).last()?)
|
||||
}
|
||||
|
||||
/// A path in the format's path operators (`m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`; any other
|
||||
/// operator ends the numbers before it).
|
||||
fn path(text: &[u8]) -> Option<BezPath> {
|
||||
let mut bp = BezPath::new();
|
||||
let mut nums: Vec<f64> = vec![];
|
||||
let mut cur: Option<Point> = None;
|
||||
for w in words(text) {
|
||||
if let Some(v) = number(w) {
|
||||
nums.push(v);
|
||||
continue;
|
||||
}
|
||||
let p = |i: usize| -> Option<Point> { Some(Point::new(*nums.get(i)?, *nums.get(i + 1)?)) };
|
||||
let n = nums.len();
|
||||
match w {
|
||||
b"m" if n >= 2 => {
|
||||
let q = p(n - 2)?;
|
||||
bp.move_to(q);
|
||||
cur = Some(q);
|
||||
}
|
||||
b"l" | b"L" if n >= 2 && cur.is_some() => {
|
||||
let q = p(n - 2)?;
|
||||
bp.line_to(q);
|
||||
cur = Some(q);
|
||||
}
|
||||
b"c" | b"C" if n >= 6 && cur.is_some() => {
|
||||
let q = p(n - 2)?;
|
||||
bp.curve_to(p(n - 6)?, p(n - 4)?, q);
|
||||
cur = Some(q);
|
||||
}
|
||||
b"v" | b"V" if n >= 4 => {
|
||||
let c0 = cur?;
|
||||
let q = p(n - 2)?;
|
||||
bp.curve_to(c0, p(n - 4)?, q);
|
||||
cur = Some(q);
|
||||
}
|
||||
b"y" | b"Y" if n >= 4 && cur.is_some() => {
|
||||
let q = p(n - 2)?;
|
||||
bp.curve_to(p(n - 4)?, q, q);
|
||||
cur = Some(q);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
nums.clear();
|
||||
}
|
||||
(bp.elements().len() >= 2).then_some(bp)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kurbo::Shape;
|
||||
|
||||
#[test]
|
||||
fn each_text_object_has_its_own_frame_by_story_and_frame() {
|
||||
let doc = b"/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r49 /StoryIndex ,\r/Art :\rX=\r7 As\r10 20 m\r110 20 L\r110 80 L\rN\rX+\r;\r/ConfiningPath ,\r;\r\
|
||||
/AI11Text :\r%_0 /FreeUndo ,\r%_1 /FrameIndex ,\r%_60 /StoryIndex ,\r%_/Art :\r%_X=\r%_300 20 m\r%_340 20 350 30 360 40 c\r%_N\r%_;\r%_/ConfiningPath ,\r%_;\r\
|
||||
/AI11Text :\r1 /FreeUndo ,\r0 /FrameIndex ,\r33 /StoryIndex ,\r2 /TextAntialiasing ,\r;\r";
|
||||
let f = text_frames(doc);
|
||||
assert_eq!(f.len(), 2, "point type has no frame: {f:?}");
|
||||
assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(10.0, 20.0, 110.0, 80.0));
|
||||
assert_eq!(f[&(60, 1)].elements().len(), 2, "a curve, led by %_");
|
||||
assert!(text_frames(b"/AI11Text : garbage").is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -258,7 +258,7 @@ impl<'a> Lexer<'a> {
|
|||
}
|
||||
|
||||
/// A finite number token (`12`, `-3.5`, `.5`, `1e-3`), or `None`.
|
||||
fn number(w: &[u8]) -> Option<f64> {
|
||||
pub(super) fn number(w: &[u8]) -> Option<f64> {
|
||||
let first = *w.first()?;
|
||||
if !(first.is_ascii_digit() || matches!(first, b'+' | b'-' | b'.')) {
|
||||
return None;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
//! objects and points. Anything it doesn't read is an error: the caller opens the file the way it
|
||||
//! did before (the printed page, or the PDF), with a warning saying why.
|
||||
|
||||
mod frames;
|
||||
mod lex;
|
||||
mod obj;
|
||||
mod paint;
|
||||
|
|
@ -27,6 +28,7 @@ use std::io::Read as _;
|
|||
use super::lex::find;
|
||||
use crate::ps;
|
||||
|
||||
pub(crate) use frames::text_frames;
|
||||
pub use read::{Structure, artboards, read};
|
||||
pub(crate) use read::{slot_frame, slot_of};
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ use std::borrow::Cow;
|
|||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect, Vec2};
|
||||
|
||||
use super::Imported;
|
||||
use super::ai::{self, Structure, slot_of};
|
||||
|
|
@ -78,6 +78,8 @@ struct SlotInfo {
|
|||
frame: usize,
|
||||
/// It, its layer and the objects round it show.
|
||||
shown: bool,
|
||||
/// Its own frame or path, where the file places it ([`ai::text_frames`]).
|
||||
art_path: Option<PathData>,
|
||||
}
|
||||
|
||||
/// The text slots of the layers and objects, in order.
|
||||
|
|
@ -85,7 +87,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, frame: ai::slot_frame(n.name.as_deref()), shown });
|
||||
out.push(SlotInfo { story, frame: ai::slot_frame(n.name.as_deref()), shown, art_path: None });
|
||||
} else if let Some(c) = n.children().filter(|_| !matches!(n.kind, NodeKind::Compound { .. })) {
|
||||
collect_slots(c, shown, out);
|
||||
}
|
||||
|
|
@ -473,8 +475,24 @@ fn plan(
|
|||
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);
|
||||
let Some(mut node) = story.node(NodeId(0), info.frame, t, to_doc) else { continue };
|
||||
let mut region = story.region(t, to_doc);
|
||||
// Area type and type on a path in the object's own frame: the text document's frame
|
||||
// of a copied object keeps its original's path.
|
||||
if let (Some(path), NodeKind::Text(text)) = (&info.art_path, &mut node.kind) {
|
||||
let size = text.runs.iter().map(|r| r.style.size).fold(0.0, f64::max);
|
||||
match &mut text.kind {
|
||||
TextKind::Area { frame } => {
|
||||
*frame = path.clone();
|
||||
region = path.bounds();
|
||||
}
|
||||
TextKind::OnPath { path: on, .. } => {
|
||||
*on = path.clone();
|
||||
region = path.bounds().map(|r| r.inflate(size * 1.5, size * 1.5));
|
||||
}
|
||||
TextKind::Point => {}
|
||||
}
|
||||
}
|
||||
if let Some(c) = content.get_mut(k) {
|
||||
*c = Content::Made(Box::new(node), region.filter(|_| info.shown), false, vec![]);
|
||||
}
|
||||
|
|
@ -860,6 +878,11 @@ fn build(data: &[u8], visible: Option<&Document>, page: Page, outlined: bool) ->
|
|||
}
|
||||
let mut infos = vec![];
|
||||
collect_slots(&doc.layers, true, &mut infos);
|
||||
// Each text object's own frame or path, by its story and frame.
|
||||
let frames = ai::text_frames(data);
|
||||
for info in &mut infos {
|
||||
info.art_path = info.story.and_then(|s| frames.get(&(s, info.frame))).map(|p| PathData::from_bezpath(&(to_doc * p.clone())));
|
||||
}
|
||||
// Type asked for as outlines is the page's: the text objects that show can't be filled.
|
||||
if outlined && infos.iter().any(|i| i.shown) {
|
||||
return Err("its type is to come in as outlines, which only its page has".into());
|
||||
|
|
|
|||
|
|
@ -806,3 +806,20 @@ fn shown_type_the_page_draws_in_pieces_comes_whole_from_the_text_document() {
|
|||
assert_eq!(all, ["Letters"], "{:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("page's type")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_copied_area_type_object_is_framed_where_its_own_art_path_is() {
|
||||
// The text document's frame is the original's (art 10..60 × 20..40); this copy's own `/Art`
|
||||
// path puts it at 30..80 × 50..70.
|
||||
let area = format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(10.0, 20.0, 60.0, 40.0));
|
||||
let doc = document_of(&[area], &[story("Copied\r", &[0], "/0 0", "/1 8.0")]);
|
||||
let copy = "/AI11Text :\n0 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n/Art :\nX=\n30 50 m\n80 50 L\n80 70 L\n30 70 L\nN\n;\n/ConfiningPath ,\n2 /TextAntialiasing ,\n;\n";
|
||||
let art = native_with_text(&(layer("Back", true, &square(10, 10)) + &layer("Spare", false, copy)), &doc);
|
||||
let r = import(&eps(&page_square(10, 10), &art)).unwrap();
|
||||
let texts = text_of(&r.document.layers[1]);
|
||||
assert_eq!(texts.len(), 1, "{:?}", r.warnings);
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &texts[0].kind else { panic!("area type: {:?}", texts[0].kind) };
|
||||
// Art y 50..70 is 30..50 down the 100 pt page.
|
||||
let b = frame.bounds().unwrap();
|
||||
assert!((b.x0 - 30.0).abs() < 1e-6 && (b.y0 - 30.0).abs() < 1e-6 && (b.x1 - 80.0).abs() < 1e-6 && (b.y1 - 50.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue