mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 01:20:09 -06:00
Merge pull request #5082 from dotCooCoo/fix/ai-os17-wrap
.ai import: area type flows around its text wrap objects
This commit is contained in:
@@ -12,6 +12,7 @@ mod object;
|
||||
mod para_style;
|
||||
mod runs;
|
||||
mod tabs_lists;
|
||||
mod wrap;
|
||||
|
||||
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
|
||||
use char_style::yes;
|
||||
@@ -22,6 +23,7 @@ pub use para_style::{
|
||||
};
|
||||
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
|
||||
pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop};
|
||||
pub use wrap::{resolve_wrap, wrap_work};
|
||||
|
||||
/// A text object. `runs` split into paragraphs at `\n`.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Object → Text Wrap: area type flows around the wrap objects stacked above it in the same layer.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{PathData, shapes};
|
||||
|
||||
use super::{TextKind, WrapShape};
|
||||
use crate::{Document, Node, NodeId, NodeKind};
|
||||
|
||||
/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art.
|
||||
fn outline(n: &Node, out: &mut PathData) {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, .. } => {
|
||||
out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned())
|
||||
}
|
||||
NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => {
|
||||
for c in children.iter().filter(|c| c.visible) {
|
||||
outline(c, out);
|
||||
}
|
||||
}
|
||||
// A clip group wraps by its clipping path.
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
if let Some(c) = children.first() {
|
||||
outline(c, out);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(b) = n.geometric_bounds() {
|
||||
out.subpaths.extend(shapes::rectangle(b).subpaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Item<'a> {
|
||||
Wrap(&'a Node),
|
||||
Text(&'a Node),
|
||||
}
|
||||
|
||||
fn collect<'a>(n: &'a Node, out: &mut Vec<Item<'a>>) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) {
|
||||
out.push(Item::Wrap(n));
|
||||
return;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n)),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
for c in children {
|
||||
collect(c, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// How much [`resolve_wrap`] would copy into the wrap shapes of every area text object in `doc`:
|
||||
/// each wrap object's outline (its anchors, plus one for the shape) once for each area text object
|
||||
/// below it in its layer.
|
||||
pub fn wrap_work(doc: &Document) -> usize {
|
||||
let mut total = 0usize;
|
||||
for layer in &doc.layers {
|
||||
let mut items = vec![];
|
||||
collect(layer, &mut items);
|
||||
let mut above = 0usize;
|
||||
for it in items.iter().rev() {
|
||||
match it {
|
||||
Item::Wrap(n) => {
|
||||
let mut pd = PathData::default();
|
||||
outline(n, &mut pd);
|
||||
if !pd.is_empty() {
|
||||
let anchors: usize = pd.subpaths.iter().map(|sp| sp.anchors.len()).sum();
|
||||
above = above.saturating_add(anchors.saturating_add(1));
|
||||
}
|
||||
}
|
||||
Item::Text(_) => total = total.saturating_add(above),
|
||||
}
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
/// Resolve the wrap shapes (`TextObject::wrap`, text space) of each area text object from the
|
||||
/// outlines of the wrap objects above it in its layer. With `before`, the layers it holds unchanged
|
||||
/// are skipped; without it, every layer is resolved. Returns the area text objects whose shapes
|
||||
/// changed, whose cached bounds are then out of date.
|
||||
pub fn resolve_wrap(before: Option<&Document>, doc: &mut Document) -> Vec<NodeId> {
|
||||
let mut updates: Vec<(NodeId, Vec<WrapShape>)> = vec![];
|
||||
for (idx, layer) in doc.layers.iter().enumerate() {
|
||||
if let Some(before) = before
|
||||
&& (before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer))
|
||||
|| before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut items = vec![];
|
||||
collect(layer, &mut items);
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// From the top down, each wrap object's outline is made once; each area text object takes
|
||||
// those above it, bottom first.
|
||||
let mut above: Vec<WrapShape> = vec![];
|
||||
for it in items.iter().rev() {
|
||||
match it {
|
||||
Item::Wrap(n) => {
|
||||
let mut pd = PathData::default();
|
||||
outline(n, &mut pd);
|
||||
if !pd.is_empty() {
|
||||
above.push(WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() });
|
||||
}
|
||||
}
|
||||
Item::Text(n) => {
|
||||
let NodeKind::Text(t) = &n.kind else { continue };
|
||||
let inv = t.xf.inverse();
|
||||
let shapes: Vec<WrapShape> = above
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|s| {
|
||||
let mut path = s.path.clone();
|
||||
path.transform(inv);
|
||||
WrapShape { path, wrap: s.wrap }
|
||||
})
|
||||
.collect();
|
||||
if shapes != t.wrap {
|
||||
updates.push((n.id, shapes));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut changed = Vec::with_capacity(updates.len());
|
||||
for (id, shapes) in updates {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.wrap = shapes;
|
||||
changed.push(id);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
@@ -1106,6 +1106,60 @@ fn texts(s: &Session) -> Vec<String> {
|
||||
art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.plain_text()) } else { None }).collect()
|
||||
}
|
||||
|
||||
/// A `.ai` saved without its PDF part whose layer `Art` holds a story in two area type frames
|
||||
/// (art x 10 to 90 and 110 to 190, y 10 to 90), then a band across the first frame (y 30 to 70),
|
||||
/// made a wrap object with no offset when `wrap` is set.
|
||||
fn wrapped_thread_ai(wrap: bool) -> Vec<u8> {
|
||||
// The template box is centred at (100, 50), the canvas centre 8191.5: y runs down the canvas.
|
||||
let canvas = |x: f64, y: f64| (x + 8091.5, 8241.5 - y);
|
||||
let rect = |x0: f64, y0: f64, x1: f64, y1: f64| {
|
||||
let corners = [canvas(x0, y1), canvas(x0, y0), canvas(x1, y0), canvas(x1, y1), canvas(x0, y1)];
|
||||
let segments: Vec<String> =
|
||||
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();
|
||||
format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", segments.join(" "))
|
||||
};
|
||||
let story = "Words keep flowing from the first frame into the second one, line after line, until the story ends here. ".repeat(3);
|
||||
let n = story.chars().count() + 1;
|
||||
let document = format!(
|
||||
"/0 << /1 << /0 [ << /0 << /0 << /0 (Helvetica) >> >> >> ] >> /8 << /0 [ {} {} ] >> >>\n\
|
||||
/1 << /1 [ << /0 << /0 ({story}\r) /5 << /0 [ << /0 << /0 << /0 () /5 << /0 0 >> /6 0 >> >> /1 {n} >> ] >> \
|
||||
/6 << /0 [ << /0 << /0 << /0 () /5 0 /6 << /1 10.0 >> >> >> /1 {n} >> ] >> >> /1 << /0 [ << /0 0 >> << /0 1 >> ] >> >> ] /2 << /1 12.0 >> >>\n",
|
||||
rect(10.0, 10.0, 90.0, 90.0),
|
||||
rect(110.0, 10.0, 190.0, 90.0)
|
||||
);
|
||||
let lines: Vec<String> = ascii85(document.as_bytes()).as_bytes().chunks(60).map(|c| format!("%{}", String::from_utf8_lossy(c))).collect();
|
||||
let frame = |f: u32| format!("/AI11Text :\n0 /FreeUndo ,\n{f} /FrameIndex ,\n0 /StoryIndex ,\n;\n");
|
||||
let dict = if wrap { "%_/ArtDictionary :\n%_0 /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n" } else { "" };
|
||||
let art = format!("{}{}0.5 g\n{}f\n{dict}", frame(0), frame(1), ai::rect(10.0, 30.0, 90.0, 70.0));
|
||||
let body = format!(
|
||||
"%AI3_TemplateBox: 100 50 100 50\n{}%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n{}\n%AI11_EndTextDocument\n",
|
||||
ai::layer("Art", &art),
|
||||
lines.join("\n")
|
||||
);
|
||||
ai::ai(&compressed(ai::editing_data(200.0, 100.0, &body).as_bytes()), PLACEHOLDER)
|
||||
}
|
||||
|
||||
/// Threaded area type below a wrap object flows around it when the file opens: the text the wrap
|
||||
/// object pushes out of the first frame goes on in the second.
|
||||
#[test]
|
||||
fn wrap_threaded_area_after_open() {
|
||||
let second = |wrap: bool| {
|
||||
let mut s = Session::new();
|
||||
let r = open(&mut s, "thread.ai", &wrapped_thread_ai(wrap), json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.text_threads.len(), 1, "{r}");
|
||||
let text = |id: NodeId| match &doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => t.clone(),
|
||||
_ => panic!("area type"),
|
||||
};
|
||||
let (first, second) = (text(doc.text_threads[0][0]), text(doc.text_threads[0][1]));
|
||||
assert_eq!(first.wrap.len(), usize::from(wrap), "{r}");
|
||||
second.plain_text().chars().count()
|
||||
};
|
||||
let (plain, wrapped) = (second(false), second(true));
|
||||
assert!(wrapped > plain, "the second frame holds more of the story: {plain} → {wrapped}");
|
||||
}
|
||||
|
||||
/// The note that glyphs were kept as outlines is dropped when the type comes from the editing data's
|
||||
/// text document, and stays with the PDF part alone.
|
||||
#[test]
|
||||
|
||||
@@ -4,11 +4,8 @@
|
||||
//! resolves, for each area text object, the outlines of the wrap objects above it into
|
||||
//! `TextObject::wrap` (text space); the layout engine subtracts them from each line's span.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind, TextKind, TextWrap, WrapShape};
|
||||
use vectorcraft_geom::{PathData, shapes};
|
||||
use vectorcraft_doc::{Document, NodeId, NodeKind, TextWrap};
|
||||
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bool_or, checked_ids_or_selection, cmd, f64_or, has_selection};
|
||||
@@ -102,93 +99,10 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "offset": w.offset, "invert": w.invert }))
|
||||
}
|
||||
|
||||
/// Outline of a wrap object in document coordinates: its paths, or its bounds for other art.
|
||||
fn outline(n: &Node, out: &mut PathData) {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, .. } => {
|
||||
out.subpaths.extend(path.subpaths.iter().filter(|sp| sp.closed || sp.anchors.len() > 2).cloned())
|
||||
}
|
||||
NodeKind::Compound { children, .. } | NodeKind::Group { children, clip: false } => {
|
||||
for c in children.iter().filter(|c| c.visible) {
|
||||
outline(c, out);
|
||||
}
|
||||
}
|
||||
// A clip group wraps by its clipping path.
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
if let Some(c) = children.first() {
|
||||
outline(c, out);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(b) = n.geometric_bounds() {
|
||||
out.subpaths.extend(shapes::rectangle(b).subpaths);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Item<'a> {
|
||||
Wrap(&'a Node),
|
||||
Text(NodeId),
|
||||
}
|
||||
|
||||
fn collect<'a>(n: &'a Node, out: &mut Vec<Item<'a>>) {
|
||||
if !n.visible {
|
||||
return;
|
||||
}
|
||||
if n.extras().wrap.is_some() && !matches!(n.kind, NodeKind::Layer { .. }) {
|
||||
out.push(Item::Wrap(n));
|
||||
return;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. }) => out.push(Item::Text(n.id)),
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
|
||||
for c in children {
|
||||
collect(c, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-resolve the wrap shapes of every area text object (called after each edit).
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) {
|
||||
let mut updates: Vec<(NodeId, Vec<WrapShape>)> = vec![];
|
||||
for (idx, layer) in doc.layers.iter().enumerate() {
|
||||
if before.layers.get(idx).is_some_and(|b| Arc::ptr_eq(b, layer)) || before.layers.iter().any(|b| b.id == layer.id && Arc::ptr_eq(b, layer)) {
|
||||
continue;
|
||||
}
|
||||
let mut items = vec![];
|
||||
collect(layer, &mut items);
|
||||
if items.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
let Item::Text(id) = it else { continue };
|
||||
let Some(NodeKind::Text(t)) = doc.node(*id).map(|n| &n.kind) else { continue };
|
||||
let inv = t.xf.inverse();
|
||||
let shapes: Vec<WrapShape> = items[i + 1..]
|
||||
.iter()
|
||||
.filter_map(|w| match w {
|
||||
Item::Wrap(n) => {
|
||||
let mut pd = PathData::default();
|
||||
outline(n, &mut pd);
|
||||
(!pd.is_empty()).then(|| {
|
||||
pd.transform(inv);
|
||||
WrapShape { path: pd, wrap: n.extras().wrap.unwrap_or_default() }
|
||||
})
|
||||
}
|
||||
Item::Text(_) => None,
|
||||
})
|
||||
.collect();
|
||||
if shapes != t.wrap {
|
||||
updates.push((*id, shapes));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (id, shapes) in updates {
|
||||
for id in vectorcraft_doc::text::resolve_wrap(Some(before), doc) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.wrap = shapes;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3548,6 +3548,37 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Text wrap in editing data: an object's dictionary with the wrap options in any order, missing,
|
||||
/// repeated or of the wrong type, with numbers that aren't finite, nested dictionaries and the kept
|
||||
/// outline's art; and wraparound groups (`*w` … `*W`), unmatched or nested.
|
||||
fn arb_ai_wrap_entry() -> impl Strategy<Value = String> {
|
||||
let value = prop_oneof![
|
||||
arb_num(),
|
||||
Just("nan".to_string()),
|
||||
Just("inf".to_string()),
|
||||
Just("-1e999".to_string()),
|
||||
Just("1e-999".to_string()),
|
||||
Just("(far)".to_string()),
|
||||
Just("/Name".to_string()),
|
||||
Just("[ 1 2 ]".to_string()),
|
||||
];
|
||||
let ty = prop::sample::select(vec!["/Real", "/Bool", "/Int", "/String", ""]);
|
||||
let key = prop::sample::select(vec!["AI11TextWrapOffset", "AI11TextWrapInvert", "AI11TextWrapPath", "AI11TextWrapBeingComputed"]);
|
||||
let entry = (value, ty, key).prop_map(|(v, t, k)| format!("%_{v} {t} ({k}) ,"));
|
||||
let art = prop::sample::select(vec![
|
||||
"%_X=\n%_0 0 m\n%_0 9 L\n%_9 9 L\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,",
|
||||
"%_X=\n%_X=\n%_0 0 m\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,",
|
||||
"%_X+\n%_ (AI11TextWrapPath) ,",
|
||||
"%_/Dictionary :\n%_6 /Real (AI11TextWrapOffset) ,\n%_; (Nested) ,",
|
||||
"%_/Dictionary :\n%_1 /Bool (AI11TextWrapInvert) ,",
|
||||
])
|
||||
.prop_map(str::to_string);
|
||||
let dictionary = prop::collection::vec(prop_oneof![3 => entry, 1 => art], 0..6)
|
||||
.prop_map(|entries| format!("\n%_/ArtDictionary :\n{}\n%_;\n%_\n", entries.join("\n")));
|
||||
let group = prop::sample::select(vec!["*w", "*W", "*w *w", "*W *W", "*w u", "U *W", "*w 1 To", "TO *W"]).prop_map(str::to_string);
|
||||
prop_oneof![2 => dictionary, 1 => group]
|
||||
}
|
||||
|
||||
/// The testkit's sample editing data, compressed as older `.ai` files compress it.
|
||||
fn ai_sample_compressed() -> Vec<u8> {
|
||||
[&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat()
|
||||
@@ -3611,7 +3642,7 @@ proptest! {
|
||||
/// Hostile editing data in Illustrator EPS and `.ai` files: operators in any order with any
|
||||
/// operands, sections and dictionaries left open, images of any size.
|
||||
#[test]
|
||||
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(arb_ai_token(), 0..80)) {
|
||||
fn ai_hostile_editing_data_never_panics(tokens in prop::collection::vec(prop_oneof![8 => arb_ai_token(), 1 => arb_ai_wrap_entry()], 0..80)) {
|
||||
use vectorcraft_testkit::ai;
|
||||
let data = ai::editing_data(200.0, 100.0, &format!("%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n{}\nLB\n", tokens.join(" ")));
|
||||
survive("hostile editing data", || Some(vectorcraft_eps::layered_ai(data.as_bytes(), Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?;
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
//! canvas (16,383 points around the template box's centre) or from edge to edge of one artboard
|
||||
//! is a ruler guide on that layer ([`Document::guides`]); other guide paths stay paths.
|
||||
//! - Groups `u` … `U`, compound paths `*u` … `*U`, clipping groups `q` … `Q` (the path painted
|
||||
//! with `W` clips); a group's transparency and name follow its end.
|
||||
//! with `W` clips); a group's transparency and name follow its end. A wraparound group `*w` …
|
||||
//! `*W` (version 7 specification §15.4.2) is a group whose objects other than its type wrap
|
||||
//! area type, with no offset.
|
||||
//! - Paths: `m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, painted by `N n F f S s B b` (lower case
|
||||
//! closes), `(op) *` for a guide. Stroke `w J j M d`, fill rule `XR`, overprint `O`/`R`.
|
||||
//! - State: hidden `Xw`, locked `A`, transparency `mode opacity isolate knockout shape Xy`.
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::document::slot_of;
|
||||
use super::ops::{paints_apart, path_rule};
|
||||
use super::types::{Frame, GState, Kind, MAX_DEPTH, MAX_NODES, PendingEnvelope};
|
||||
use super::{AppearanceItem, Arc, BezPath, BlendMode, FillRule, HashSet, LayerColor, Node, NodeId, NodeKind, Obj, Paint, PathEl, Rc, Reader, V, obj};
|
||||
use vectorcraft_doc::TextWrap;
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
// ---- frames ----------------------------------------------------------------------------
|
||||
@@ -77,11 +78,12 @@ impl<'a> Reader<'a> {
|
||||
let f = match what {
|
||||
"U" => self.close(what, |k| matches!(k, Kind::Group))?,
|
||||
"*U" => self.close(what, |k| matches!(k, Kind::Compound))?,
|
||||
"*W" => self.close(what, |k| matches!(k, Kind::Wrap))?,
|
||||
_ => self.close(what, |k| matches!(k, Kind::Clip))?,
|
||||
};
|
||||
let Frame {
|
||||
kind,
|
||||
children: kids,
|
||||
children: mut kids,
|
||||
gs,
|
||||
clips,
|
||||
hidden: opened_hidden,
|
||||
@@ -96,6 +98,15 @@ impl<'a> Reader<'a> {
|
||||
self.pending_unread_fill = pending_unread_fill;
|
||||
self.pending_unread_stroke = pending_unread_stroke;
|
||||
}
|
||||
// Wraparound text: every object in it other than type is a wrap object, before the text or
|
||||
// after it, with no offset (the format gives none) unless its dictionary gives its options.
|
||||
// One before the text is below it, so the text flows around it only once it is moved above.
|
||||
if matches!(kind, Kind::Wrap) {
|
||||
let graphics = kids.iter_mut().filter(|k| !matches!(k.kind, NodeKind::Text(_)) && slot_of(k.name.as_deref()).is_none());
|
||||
for k in graphics.filter(|k| k.extras().wrap.is_none()) {
|
||||
k.extras_mut().wrap = Some(TextWrap { offset: 0.0, invert: false });
|
||||
}
|
||||
}
|
||||
// A group holding only clipping paths (`q … u <path> W n U … Q`) doesn't clip on its own: `u`/`U` don't save
|
||||
// the graphics state as `q`/`Q` do, so its paths clip the clipping group around it, as the file's PDF part draws it.
|
||||
if matches!(kind, Kind::Group)
|
||||
@@ -423,7 +434,7 @@ impl<'a> Reader<'a> {
|
||||
/// Mark the first object container below the innermost layer.
|
||||
pub(super) fn mark_unread(&mut self, what: String) -> bool {
|
||||
let Some(layer) = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))) else { return false };
|
||||
let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)) else {
|
||||
let Some(frame) = self.frames.get_mut(layer + 1).filter(|f| matches!(f.kind, Kind::Group | Kind::Wrap | Kind::Compound | Kind::Clip)) else {
|
||||
return false;
|
||||
};
|
||||
if !frame.unread.contains(&what) {
|
||||
@@ -443,11 +454,9 @@ impl<'a> Reader<'a> {
|
||||
self.unreadable(what, false);
|
||||
return Ok(None);
|
||||
}
|
||||
let in_container = self
|
||||
.frames
|
||||
.iter()
|
||||
.rposition(|f| matches!(f.kind, Kind::Layer(_)))
|
||||
.is_some_and(|layer| self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Compound | Kind::Clip)));
|
||||
let in_container = self.frames.iter().rposition(|f| matches!(f.kind, Kind::Layer(_))).is_some_and(|layer| {
|
||||
self.frames.get(layer + 1).is_some_and(|f| matches!(f.kind, Kind::Group | Kind::Wrap | Kind::Compound | Kind::Clip))
|
||||
});
|
||||
if !super::art::sane(bounds) || bounds.area() <= 0.0 {
|
||||
self.unsupported.insert(what.into());
|
||||
return Ok(None);
|
||||
|
||||
@@ -5,7 +5,7 @@ use super::{
|
||||
Affine, Arc, Artboard, CANVAS_CENTRE, ColorMode, Document, Node, NodeId, NodeKind, Obj, OpacityMask, Paint, Point, Reader, Rect, Structure,
|
||||
Swatch, Unit, V, obj,
|
||||
};
|
||||
use vectorcraft_doc::{Guide, ImageBlob, PatternDef};
|
||||
use vectorcraft_doc::{Guide, ImageBlob, PatternDef, TextWrap};
|
||||
|
||||
/// `%AI5_RulerUnits` → [`Unit`] (Illustrator 7 spec table 3; centimeters is 4; pixels is 6 in later files).
|
||||
fn ruler_unit(v: u8) -> Option<Unit> {
|
||||
@@ -220,6 +220,23 @@ fn variable_sets_node(data: &[u8]) -> Option<&[u8]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text wrap options an object's dictionary gives: its offset in points (`AI11TextWrapOffset`,
|
||||
/// times `scale`, the factor that takes the art onto the document) and Invert Wrap
|
||||
/// (`AI11TextWrapInvert`). The outline the file may keep with them (`AI11TextWrapPath`) is not
|
||||
/// read: the object's own shape gives it. A value that isn't a number is left at its default, and
|
||||
/// a dictionary without a readable entry gives none.
|
||||
fn text_wrap(o: &Obj, scale: f64) -> Option<TextWrap> {
|
||||
let num = |key: &str| o.get(key).and_then(|v| v.iter().find_map(V::num)).filter(|v| v.is_finite());
|
||||
let offset = num("AI11TextWrapOffset");
|
||||
let invert = num("AI11TextWrapInvert").filter(|v| *v == 0.0 || *v == 1.0);
|
||||
if offset.is_none() && invert.is_none() && o.get("AI11TextWrapPath").is_none() {
|
||||
return None;
|
||||
}
|
||||
let default = TextWrap::default();
|
||||
let offset = offset.map(|v| v * scale).filter(|v| !v.is_nan()).map_or(default.offset, |v| v.clamp(-1000.0, 1000.0));
|
||||
Some(TextWrap { offset, invert: invert == Some(1.0) })
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
/// An object's dictionary: its name (`AIArtName`, or the XML id `AI10_ArtUID`), and the
|
||||
/// opacity mask it may carry.
|
||||
@@ -263,6 +280,12 @@ impl<'a> Reader<'a> {
|
||||
self.unreadable("opacity masks without readable art", false);
|
||||
}
|
||||
}
|
||||
// Text wrap: area type below the object in its layer flows around it.
|
||||
if let Some(wrap) = text_wrap(o, self.large_canvas.unwrap_or(1.0))
|
||||
&& let Some(n) = self.last_object().filter(|n| !matches!(n.kind, NodeKind::Layer { .. }))
|
||||
{
|
||||
n.extras_mut().wrap = Some(wrap);
|
||||
}
|
||||
let name = match o.text("AIArtName") {
|
||||
Some(name) => name,
|
||||
None => match o.obj("AI10_ArtUID").and_then(Obj::content_text) {
|
||||
|
||||
@@ -125,11 +125,20 @@ impl<'a> Reader<'a> {
|
||||
self.end_path_quietly();
|
||||
self.open(Kind::Compound, hidden)?
|
||||
}
|
||||
// A wraparound group takes no operands and isn't an element of a compound path.
|
||||
"*w" | "*W" if !vals.is_empty() => return Err(format!("`{w}` takes no operands")),
|
||||
"*w" if self.frames.iter().any(|f| matches!(f.kind, Kind::Compound)) => {
|
||||
return Err("a wraparound group is inside a compound path".into());
|
||||
}
|
||||
"*w" => {
|
||||
self.end_path_quietly();
|
||||
self.open(Kind::Wrap, hidden)?
|
||||
}
|
||||
"q" => {
|
||||
self.end_path_quietly();
|
||||
self.open(Kind::Clip, hidden)?
|
||||
}
|
||||
"U" | "*U" | "Q" => self.end_container(w, hidden)?,
|
||||
"U" | "*U" | "*W" | "Q" => self.end_container(w, hidden)?,
|
||||
"Lb" => self.layer_attrs(&vals),
|
||||
"Ln" => {
|
||||
if let Some(Frame { kind: Kind::Layer(a), .. }) = self.frames.last_mut() {
|
||||
|
||||
@@ -142,6 +142,9 @@ pub(super) struct LayerAttrs {
|
||||
pub(super) enum Kind {
|
||||
Layer(Box<LayerAttrs>),
|
||||
Group,
|
||||
/// Wraparound text (`*w` … `*W`, version 7 specification §15.4.2): a group whose objects other
|
||||
/// than its type are wrap objects.
|
||||
Wrap,
|
||||
Compound,
|
||||
Clip,
|
||||
Mask,
|
||||
|
||||
@@ -3247,6 +3247,131 @@ fn a_blend_record_is_read_to_its_length() {
|
||||
assert!(matches!(blend.kind, NodeKind::Blend { .. }), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// An object's dictionary with the text wrap `entries` (each a `values (key) ,` line).
|
||||
fn wrap_dictionary(entries: &[&str]) -> String {
|
||||
let lines: String = entries.iter().map(|e| format!("%_{e}\n")).collect();
|
||||
format!("%_/ArtDictionary :\n{lines}%_;\n%_\n")
|
||||
}
|
||||
|
||||
/// The wrap outline a file may keep beside the options: art, then its key.
|
||||
const WRAP_PATH: &str = "X=\n%_4 As\n%_0 0 m\n%_0 20 L\n%_20 20 L\n%_20 0 L\n%_f\n%_X+\n%_ (AI11TextWrapPath) ,";
|
||||
|
||||
fn wrap_of(n: &Node) -> Option<vectorcraft_doc::TextWrap> {
|
||||
n.extras().wrap
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_dictionary_path() {
|
||||
let dict = wrap_dictionary(&[WRAP_PATH, "0 /Bool (AI11TextWrapInvert) ,", "12.5 /Real (AI11TextWrapOffset) ,"]);
|
||||
let r = read_ok(&layer(&format!("{RED}{}f\n{dict}", rect_path(10.0, 10.0, 50.0, 50.0))));
|
||||
let kids = art(&r.doc);
|
||||
assert_eq!(kids.len(), 1, "the kept outline isn't art: {:?}", r.warnings);
|
||||
assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: false }));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_dictionary_invert_and_default() {
|
||||
let inverted = wrap_dictionary(&["12.5 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]);
|
||||
let default = wrap_dictionary(&["0 /Bool (AI11TextWrapInvert) ,"]);
|
||||
let plain = wrap_dictionary(&["(Plain) /String (AIArtName) ,"]);
|
||||
let r = read_ok(&layer(&format!(
|
||||
"{RED}{}f\n{inverted}{}f\n{default}{}f\n{plain}",
|
||||
rect_path(0.0, 0.0, 10.0, 10.0),
|
||||
rect_path(20.0, 0.0, 30.0, 10.0),
|
||||
rect_path(40.0, 0.0, 50.0, 10.0)
|
||||
)));
|
||||
let kids = art(&r.doc);
|
||||
assert_eq!(kids.len(), 3);
|
||||
assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap { offset: 12.5, invert: true }));
|
||||
assert_eq!(wrap_of(&kids[1]), Some(vectorcraft_doc::TextWrap::default()), "6 pt when the offset isn't given");
|
||||
assert_eq!(wrap_of(&kids[2]), None, "an object without wrap options isn't a wrap object");
|
||||
assert_eq!(kids[2].name.as_deref(), Some("Plain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_dictionary_bad_offset() {
|
||||
let offsets = ["nan /Real", "1e308 /Real", "(far) /String", "2000 /Real", "-2000 /Real"];
|
||||
let mut body = RED.to_string();
|
||||
for (i, offset) in offsets.iter().enumerate() {
|
||||
let x = 20.0 * i as f64;
|
||||
body += &format!("{}f\n", rect_path(x, 0.0, x + 10.0, 10.0));
|
||||
body += &wrap_dictionary(&[&format!("{offset} (AI11TextWrapOffset) ,"), "1 /Bool (AI11TextWrapInvert) ,"]);
|
||||
}
|
||||
// An offset that isn't a number, with nothing else of the options, isn't a wrap object.
|
||||
body += &format!("{}f\n", rect_path(200.0, 0.0, 210.0, 10.0));
|
||||
body += &wrap_dictionary(&["(far) /String (AI11TextWrapOffset) ,", "7 /Bool (AI11TextWrapInvert) ,"]);
|
||||
let r = read_ok(&layer(&body));
|
||||
let offsets: Vec<Option<f64>> = art(&r.doc).iter().map(|n| wrap_of(n).map(|w| w.offset)).collect();
|
||||
assert_eq!(offsets, [Some(6.0), Some(1000.0), Some(6.0), Some(1000.0), Some(-1000.0), None]);
|
||||
assert!(art(&r.doc).iter().take(5).all(|n| wrap_of(n).is_some_and(|w| w.invert)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_dictionary_group() {
|
||||
let dict = wrap_dictionary(&["6 /Real (AI11TextWrapOffset) ,", "0 /Bool (AI11TextWrapInvert) ,"]);
|
||||
let r = read_ok(&layer(&format!("u\n{RED}{}f\n{}f\nU\n{dict}", rect_path(0.0, 0.0, 10.0, 10.0), rect_path(20.0, 0.0, 30.0, 10.0))));
|
||||
let kids = art(&r.doc);
|
||||
assert_eq!(kids.len(), 1);
|
||||
assert_eq!(wrap_of(&kids[0]), Some(vectorcraft_doc::TextWrap::default()));
|
||||
let NodeKind::Group { children, .. } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
|
||||
assert_eq!(children.len(), 2);
|
||||
assert!(children.iter().all(|c| wrap_of(c).is_none() && matches!(c.kind, NodeKind::Path { .. })));
|
||||
}
|
||||
|
||||
/// Wraparound text in the version 7 format (§15.4.2): `*w`, objects, the area text, the objects it
|
||||
/// wraps around, `*W`.
|
||||
#[test]
|
||||
fn legacy_wrap_group() {
|
||||
let text = format!("1 To\n1 0 0 1 0 0 0 Tp\n{}n\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n(Around) Tx\nTO\n", rect_path(0.0, 0.0, 100.0, 80.0));
|
||||
// The last object's own dictionary gives its options.
|
||||
let own = wrap_dictionary(&["3 /Real (AI11TextWrapOffset) ,", "1 /Bool (AI11TextWrapInvert) ,"]);
|
||||
let body = format!(
|
||||
"*w\n{RED}{}f\n{text}{}f\n{}f\n{own}*W\n",
|
||||
rect_path(0.0, 0.0, 5.0, 5.0),
|
||||
rect_path(30.0, 30.0, 50.0, 50.0),
|
||||
rect_path(60.0, 30.0, 70.0, 50.0)
|
||||
);
|
||||
let r = read_ok(&layer(&body));
|
||||
let kids = art(&r.doc);
|
||||
assert_eq!(kids.len(), 1, "{:?}", r.warnings);
|
||||
let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
|
||||
let wraps: Vec<(bool, bool)> = children.iter().map(|c| (matches!(c.kind, NodeKind::Text(_)), wrap_of(c).is_some())).collect();
|
||||
assert_eq!(wraps, [(false, true), (true, false), (false, true), (false, true)], "objects before the text are wrap objects too: {:?}", r.warnings);
|
||||
let none = Some(vectorcraft_doc::TextWrap { offset: 0.0, invert: false });
|
||||
assert_eq!(wrap_of(&children[0]), none, "no offset");
|
||||
assert_eq!(wrap_of(&children[2]), none, "no offset");
|
||||
assert_eq!(wrap_of(&children[3]), Some(vectorcraft_doc::TextWrap { offset: 3.0, invert: true }));
|
||||
assert!(!r.warnings.iter().any(|w| w.contains("*w") || w.contains("*W")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A wraparound group with no type in it: each of its objects is a wrap object, with no offset.
|
||||
#[test]
|
||||
fn legacy_wrap_group_without_text() {
|
||||
let body = format!("*w\n{RED}{}f\n{}f\n*W\n", rect_path(0.0, 0.0, 5.0, 5.0), rect_path(30.0, 30.0, 50.0, 50.0));
|
||||
let r = read_ok(&layer(&body));
|
||||
let kids = art(&r.doc);
|
||||
assert_eq!(kids.len(), 1, "{:?}", r.warnings);
|
||||
let NodeKind::Group { children, clip: false } = &kids[0].kind else { panic!("a group: {:?}", kids[0].kind) };
|
||||
let wraps: Vec<_> = children.iter().map(|c| wrap_of(c)).collect();
|
||||
assert_eq!(wraps, [Some(vectorcraft_doc::TextWrap { offset: 0.0, invert: false }); 2]);
|
||||
}
|
||||
|
||||
/// `*w` and `*W` take no operands, and a wraparound group can't be an element of a compound path.
|
||||
#[test]
|
||||
fn legacy_wrap_group_errors() {
|
||||
let square = format!("{RED}{}f\n", rect_path(0.0, 0.0, 5.0, 5.0));
|
||||
for (body, error) in [
|
||||
(format!("1 *w\n{square}*W\n"), "`*w` takes no operands"),
|
||||
(format!("*w\n{square}0 *W\n"), "`*W` takes no operands"),
|
||||
(format!("*u\n*w\n{square}*W\n*U\n"), "a wraparound group is inside a compound path"),
|
||||
(format!("*u\nu\n*w\n{square}*W\nU\n*U\n"), "a wraparound group is inside a compound path"),
|
||||
] {
|
||||
let e = read(stream(&layer(&body)).as_bytes()).unwrap_err();
|
||||
assert!(e.contains(error), "{body:?}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The printer's crop marks Illustrator draws after the last layer (`%AI3_BeginCrops`, inside
|
||||
/// `userdict /AI3_noCropMarks known not { … } if`) aren't art outside the layers: the file reads
|
||||
/// from its layers, without them (the crop area is `%AI3_Cropmarks`).
|
||||
|
||||
@@ -289,6 +289,8 @@ pub(super) fn build_from(
|
||||
doc.text_threads.push(thread.iter().map(|(_, _, id)| *id).collect());
|
||||
}
|
||||
}
|
||||
// Area type flows around the wrap objects above it before it is compared with the page.
|
||||
resolve_text_wrap(&mut doc)?;
|
||||
if empty > 0 {
|
||||
notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT }));
|
||||
}
|
||||
@@ -349,10 +351,47 @@ pub(super) fn build_from(
|
||||
doc.limit_depth_noting(&mut notes);
|
||||
if small.is_some() {
|
||||
scale_document(&mut doc, unit);
|
||||
relayout_wrapped(&mut doc);
|
||||
}
|
||||
Ok((doc, notes))
|
||||
}
|
||||
|
||||
/// Most anchors copied into the wrap shapes of a file's area type ([`vectorcraft_doc::text::wrap_work`]).
|
||||
const MAX_WRAP_WORK: usize = 2_000_000;
|
||||
|
||||
/// [`relayout_wrapped`], or an error when the wrap shapes would hold more than [`MAX_WRAP_WORK`]
|
||||
/// anchors.
|
||||
fn resolve_text_wrap(doc: &mut Document) -> Result<(), String> {
|
||||
if has_wraps(doc) && vectorcraft_doc::text::wrap_work(doc) > MAX_WRAP_WORK {
|
||||
return Err("it has too many text objects".into());
|
||||
}
|
||||
relayout_wrapped(doc);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Does an object of `doc` have text wrap options, or a text object wrap shapes?
|
||||
fn has_wraps(doc: &Document) -> bool {
|
||||
let mut wraps = false;
|
||||
doc.walk(|n| wraps |= n.extras().wrap.is_some() || matches!(&n.kind, NodeKind::Text(t) if !t.wrap.is_empty()));
|
||||
wraps
|
||||
}
|
||||
|
||||
/// Resolve the wrap shapes of the area type in `doc` that has wrap objects above it (see
|
||||
/// [`vectorcraft_doc::text::resolve_wrap`]); the type whose shapes changed is laid out again.
|
||||
/// Nothing is resolved when the shapes would hold more than [`MAX_WRAP_WORK`] anchors (such a
|
||||
/// file isn't read from its layers: see [`resolve_text_wrap`]).
|
||||
fn relayout_wrapped(doc: &mut Document) {
|
||||
if !has_wraps(doc) || vectorcraft_doc::text::wrap_work(doc) > MAX_WRAP_WORK {
|
||||
return;
|
||||
}
|
||||
for id in vectorcraft_doc::text::resolve_wrap(None, doc) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.cached_bounds = None;
|
||||
t.cached_baselines.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A visible page node, with its source order and subtree range.
|
||||
struct PageArt {
|
||||
node: Arc<Node>,
|
||||
@@ -836,6 +875,10 @@ fn elsewhere(doc: &Document, placed: &[(NodeId, Affine)]) -> Option<Document> {
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
// Area type wrapped around a moved image flows around it where it now is.
|
||||
if moved {
|
||||
relayout_wrapped(&mut doc);
|
||||
}
|
||||
moved.then_some(doc)
|
||||
}
|
||||
|
||||
@@ -1012,7 +1055,9 @@ fn scale_document(doc: &mut Document, k: f64) {
|
||||
let a = Affine::scale(k);
|
||||
let scaling = Scaling { strokes: true, effects: Some(vectorcraft_effects::scale_effect), patterns: true, ..Default::default() };
|
||||
for layer in &mut doc.layers {
|
||||
Arc::make_mut(layer).transform(a, scaling);
|
||||
let layer = Arc::make_mut(layer);
|
||||
layer.transform(a, scaling);
|
||||
scale_wrap_offsets(layer, k);
|
||||
}
|
||||
for board in &mut doc.artboards {
|
||||
board.rect = a.transform_rect_bbox(board.rect);
|
||||
@@ -1022,6 +1067,22 @@ fn scale_document(doc: &mut Document, k: f64) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The text wrap offsets of `n` and what it holds, times `k` (within the -1000 to 1000 points
|
||||
/// they may be).
|
||||
fn scale_wrap_offsets(n: &mut Node, k: f64) {
|
||||
if let Some(w) = n.extras_if_any().and_then(|e| e.wrap.as_mut()) {
|
||||
let v = w.offset * k;
|
||||
if !v.is_nan() {
|
||||
w.offset = v.clamp(-1000.0, 1000.0);
|
||||
}
|
||||
}
|
||||
if let Some(children) = n.children_mut() {
|
||||
for c in children {
|
||||
scale_wrap_offsets(Arc::make_mut(c), k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks (in a node's name, until [`prune_unseen`] has looked) the text objects made from the file's
|
||||
/// text document.
|
||||
pub(super) const MADE: &str = "\u{1}made";
|
||||
@@ -1102,6 +1163,8 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc
|
||||
let mut with = view.colour_difference(doc, rect);
|
||||
let mut removed = 0;
|
||||
for (id, bounds) in found {
|
||||
// A removal before it can have let its area type flow differently.
|
||||
let bounds = if removed > 0 { doc.node(id).and_then(|n| n.visual_bounds()) } else { bounds };
|
||||
if bounds.is_none_or(|b| b.intersect(rect).is_zero_area()) {
|
||||
continue;
|
||||
}
|
||||
@@ -1109,9 +1172,12 @@ pub(super) fn prune_unseen(frame: &Frame<'_>, view: &mut PageView, doc: &mut Doc
|
||||
if !take_out(&mut without.layers, id, true) {
|
||||
continue;
|
||||
}
|
||||
// Area type that wrapped around the text object flows without it.
|
||||
relayout_wrapped(&mut without);
|
||||
let d = view.colour_difference(&without, rect);
|
||||
if d - with <= UNSEEN_MARGIN {
|
||||
take_out(&mut doc.layers, id, false);
|
||||
relayout_wrapped(doc);
|
||||
with = d;
|
||||
removed += 1;
|
||||
}
|
||||
@@ -1125,6 +1191,115 @@ mod candidate_tests {
|
||||
|
||||
use vectorcraft_doc::{ImageBlob, ImageObject};
|
||||
|
||||
/// Layers read in the units of a page scaled up to points take their wrap offsets with their art,
|
||||
/// within ±1000 points.
|
||||
#[test]
|
||||
fn scaling_the_document_scales_its_wrap_offsets() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let mut ids = vec![];
|
||||
for (x, offset) in [(0.0, 6.0), (20.0, 200.0), (40.0, -150.0)] {
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Default::default());
|
||||
n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap { offset, invert: true });
|
||||
doc.insert(Some(doc.layers[0].id), 0, n).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
scale_document(&mut doc, 10.0);
|
||||
let offsets: Vec<_> = ids.iter().map(|id| doc.node(*id).and_then(|n| n.extras().wrap)).collect();
|
||||
let wrap = |offset| Some(vectorcraft_doc::TextWrap { offset, invert: true });
|
||||
assert_eq!(offsets, [wrap(60.0), wrap(1000.0), wrap(-1000.0)]);
|
||||
}
|
||||
|
||||
/// Area type in `doc`'s first layer at `index`, in the frame `r`.
|
||||
fn insert_area(doc: &mut Document, index: usize, r: Rect) -> NodeId {
|
||||
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped", Default::default());
|
||||
t.kind = vectorcraft_doc::TextKind::Area { frame: vectorcraft_geom::shapes::rectangle(r) };
|
||||
let id = doc.alloc_id();
|
||||
doc.insert(Some(doc.layers[0].id), index, Node::new(id, NodeKind::Text(Box::new(t)))).unwrap()
|
||||
}
|
||||
|
||||
/// A wrap object on top of `doc`'s first layer: a closed path of `anchors` anchors in `r`.
|
||||
fn insert_wrap(doc: &mut Document, r: Rect, anchors: usize) -> NodeId {
|
||||
let mut bp = kurbo::BezPath::new();
|
||||
bp.move_to((r.x0, r.y0));
|
||||
for i in 1..anchors {
|
||||
let t = i as f64 / anchors as f64;
|
||||
bp.line_to((r.x0 + r.width() * t, if i % 2 == 0 { r.y0 } else { r.y1 }));
|
||||
}
|
||||
bp.close_path();
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::path(id, PathData::from_bezpath(&bp), Default::default());
|
||||
n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default());
|
||||
let top = doc.layers[0].children().map_or(0, Vec::len);
|
||||
doc.insert(Some(doc.layers[0].id), top, n).unwrap()
|
||||
}
|
||||
|
||||
fn wrap_shapes(doc: &Document, id: NodeId) -> usize {
|
||||
match doc.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => t.wrap.len(),
|
||||
_ => panic!("area type"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file whose wrap shapes would copy more than `MAX_WRAP_WORK` anchors isn't read from its
|
||||
/// layers; one below that is.
|
||||
#[test]
|
||||
fn wrap_shapes_of_too_many_text_objects_are_refused() {
|
||||
for (texts, ok) in [(150, true), (250, false)] {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let ids: Vec<NodeId> = (0..texts).map(|_| insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0))).collect();
|
||||
insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 10_000);
|
||||
assert!(vectorcraft_doc::text::wrap_work(&doc) >= texts * 10_000);
|
||||
assert_eq!(resolve_text_wrap(&mut doc).is_ok(), ok, "{texts} text objects");
|
||||
// Nor is anything copied on the way, as the placed files' other placement does.
|
||||
relayout_wrapped(&mut doc);
|
||||
assert!(ids.iter().all(|id| wrap_shapes(&doc, *id) == usize::from(ok)), "{texts} text objects");
|
||||
}
|
||||
}
|
||||
|
||||
/// Area type that wrapped around an object taken out of the layers flows without it.
|
||||
#[test]
|
||||
fn wrap_shapes_go_with_their_wrap_object() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let text = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let wrap = insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 4);
|
||||
resolve_text_wrap(&mut doc).unwrap();
|
||||
assert_eq!(wrap_shapes(&doc, text), 1);
|
||||
doc.remove(wrap).unwrap();
|
||||
relayout_wrapped(&mut doc);
|
||||
assert_eq!(wrap_shapes(&doc, text), 0);
|
||||
}
|
||||
|
||||
/// Area type wrapped around a placed image flows around it at the image's other placement.
|
||||
#[test]
|
||||
fn wrap_shapes_follow_an_image_to_its_other_placement() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let text = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let image = doc.alloc_id();
|
||||
let mut n = Node::new(
|
||||
image,
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
xf: Affine::translate(Vec2::new(10.0, 10.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
);
|
||||
n.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default());
|
||||
doc.insert(Some(doc.layers[0].id), 1, n).unwrap();
|
||||
resolve_text_wrap(&mut doc).unwrap();
|
||||
let x0 = |d: &Document| match d.node(text).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => t.wrap.first().and_then(|w| w.path.bounds()).map(|b| b.x0),
|
||||
_ => None,
|
||||
};
|
||||
assert_eq!(x0(&doc), Some(10.0));
|
||||
let moved = elsewhere(&doc, &[(image, Affine::translate(Vec2::new(50.0, 0.0)))]).unwrap();
|
||||
assert_eq!(x0(&moved), Some(60.0));
|
||||
}
|
||||
|
||||
/// A page image with a different proxy keeps its editing layer and original blob.
|
||||
#[test]
|
||||
fn page_image_with_different_proxy_keeps_editing_layer() {
|
||||
|
||||
@@ -214,6 +214,8 @@ pub(super) fn fill_slots(
|
||||
let frame = ai::slot_frame(n.name.as_deref());
|
||||
let state = (n.opacity, n.blend, n.isolate, n.knockout);
|
||||
let transparent = see_through(n);
|
||||
// A text object that is a wrap object stays one.
|
||||
let wrap = n.extras().wrap;
|
||||
// An art style read onto the slot (fills, strokes and effects round the characters) is the type's.
|
||||
let styled = (!n.appearance.items.is_empty() || !n.appearance.effects.is_empty()).then(|| n.appearance.clone());
|
||||
let replacement = match content.next() {
|
||||
@@ -257,6 +259,17 @@ pub(super) fn fill_slots(
|
||||
parts.push(Arc::new(group));
|
||||
}
|
||||
}
|
||||
// A wrap object stays one: the page's parts of it in a group, when there are several.
|
||||
if wrap.is_some() {
|
||||
if parts.len() > 1 {
|
||||
let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts));
|
||||
group.visible = visible;
|
||||
parts.push(Arc::new(group));
|
||||
}
|
||||
for p in &mut parts {
|
||||
Arc::make_mut(p).extras_mut().wrap = wrap;
|
||||
}
|
||||
}
|
||||
let n = parts.len();
|
||||
nodes.splice(i..=i, parts);
|
||||
i += n;
|
||||
@@ -277,6 +290,9 @@ pub(super) fn fill_slots(
|
||||
text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) };
|
||||
// The type takes the slot's transparency.
|
||||
(text.opacity, text.blend, text.isolate, text.knockout) = state;
|
||||
if wrap.is_some() {
|
||||
text.extras_mut().wrap = wrap;
|
||||
}
|
||||
if let Some(slot) = nodes.get_mut(i) {
|
||||
*slot = Arc::new(*text);
|
||||
}
|
||||
@@ -477,3 +493,33 @@ pub(super) fn covered(object: &[Arc<Node>], content: &[Content], keep: impl Fn(u
|
||||
pub(super) fn extent(parts: &[Arc<Node>]) -> Option<Rect> {
|
||||
parts.iter().filter_map(|n| n.visual_bounds()).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A text slot that is a wrap object, filled with the page's type in two parts, stays one
|
||||
/// wrap object: a group of the parts.
|
||||
#[test]
|
||||
fn a_wrapped_slot_filled_from_the_page_stays_one_wrap_object() {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
let mut slot = Node::group(doc.alloc_id(), vec![]);
|
||||
slot.name = Some("\u{0}text0:0".into());
|
||||
let wrap = vectorcraft_doc::TextWrap { offset: 3.0, invert: true };
|
||||
slot.extras_mut().wrap = Some(wrap);
|
||||
let part = |doc: &mut Document, x: f64| {
|
||||
let t = vectorcraft_doc::TextObject::point(Point::new(x, 50.0), "Part", Default::default());
|
||||
Arc::new(Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))))
|
||||
};
|
||||
let parts = vec![part(&mut doc, 10.0), part(&mut doc, 60.0)];
|
||||
let mut nodes = vec![Arc::new(slot)];
|
||||
let mut content = vec![Content::Page(parts)].into_iter();
|
||||
let (mut empty, mut made) = (0, vec![]);
|
||||
fill_slots(&mut doc, &mut nodes, &mut content, &Default::default(), &mut empty, &mut made, &mut vec![]);
|
||||
assert_eq!(nodes.len(), 1);
|
||||
assert_eq!(nodes[0].extras().wrap, Some(wrap));
|
||||
let NodeKind::Group { children, .. } = &nodes[0].kind else { panic!("a group: {:?}", nodes[0].kind) };
|
||||
assert_eq!(children.len(), 2);
|
||||
assert!(children.iter().all(|c| c.extras().wrap.is_none() && matches!(c.kind, NodeKind::Text(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2801,6 +2801,109 @@ fn threaded_area_type_flows_through_its_frames() {
|
||||
assert_eq!(r.document.text_threads, [ids]);
|
||||
}
|
||||
|
||||
/// A filled bar of the art from x 40 to 60, the whole page high, made a wrap object with an offset
|
||||
/// of 2 points when `wrap` is set.
|
||||
fn wrap_bar(wrap: bool) -> String {
|
||||
let dict = "%_/ArtDictionary :\n%_0 /Bool (AI11TextWrapInvert) ,\n%_2 /Real (AI11TextWrapOffset) ,\n%_;\n%_\n";
|
||||
format!("0 0 1 0 k\n40 0 m\n40 100 L\n60 100 L\n60 0 L\nf\n{}", if wrap { dict } else { "" })
|
||||
}
|
||||
|
||||
/// A story long enough to fill several lines of a 10-point area type frame.
|
||||
const WRAP_STORY: &str = "Text flows around the bar in the middle of its frame when the bar is a wrap object\\r";
|
||||
|
||||
/// The `.ai` editing data of area type from x 10 to 90 of the art, with `below` before it and
|
||||
/// `above` after it in its layer, and `other` on a layer above → the document read from it alone.
|
||||
fn wrapped_area(below: &str, above: &str, other: Option<&str>) -> Document {
|
||||
let doc = document_of(&[area_frame(10.0, 10.0, 90.0, 90.0)], &[story(WRAP_STORY, &[0], "/0 0", "/1 10.0")]);
|
||||
let mut layers = layer("Art", true, &format!("{below}\n{}{above}", frame_object(0, 0)));
|
||||
if let Some(other) = other {
|
||||
layers += &layer("Top", true, other);
|
||||
}
|
||||
let (d, notes) = crate::ai_alone(&private(&native_with_text(&layers, &doc))).unwrap();
|
||||
assert!(!notes.iter().any(|n| n.contains("couldn't be read")), "{notes:?}");
|
||||
d
|
||||
}
|
||||
|
||||
/// The area type of `d` (its first text object).
|
||||
fn area_text(d: &Document) -> vectorcraft_doc::TextObject {
|
||||
let mut found = None;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& found.is_none()
|
||||
{
|
||||
found = Some((**t).clone());
|
||||
}
|
||||
});
|
||||
found.expect("area type")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_area_after_open() {
|
||||
let d = wrapped_area("", &wrap_bar(true), None);
|
||||
let t = area_text(&d);
|
||||
assert_eq!(t.wrap.len(), 1, "one wrap shape, the bar's");
|
||||
assert_eq!(t.wrap[0].wrap, vectorcraft_doc::TextWrap { offset: 2.0, invert: false });
|
||||
// Each line keeps to one side of the bar and its 2-point offset.
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert!(lay.lines.len() > 1);
|
||||
let spans: Vec<(f64, f64)> = lay.lines.iter().map(|l| l.avail).collect();
|
||||
assert!(spans.iter().all(|(a, b)| *b <= 38.0 + 1e-6 || *a >= 62.0 - 1e-6), "{spans:?}");
|
||||
assert!(spans.iter().any(|(_, b)| *b <= 38.0 + 1e-6) && spans.iter().any(|(a, _)| *a >= 62.0 - 1e-6), "{spans:?}");
|
||||
// A wrap object below the type, or on another layer, doesn't wrap it.
|
||||
assert!(area_text(&wrapped_area(&wrap_bar(true), "", None)).wrap.is_empty());
|
||||
assert!(area_text(&wrapped_area("", "", Some(&wrap_bar(true)))).wrap.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_wrap_metadata_unchanged() {
|
||||
let d = wrapped_area("", &wrap_bar(false), None);
|
||||
let t = area_text(&d);
|
||||
assert!(t.wrap.is_empty());
|
||||
let mut wraps = 0;
|
||||
d.walk(|n| wraps += usize::from(n.extras().wrap.is_some()));
|
||||
assert_eq!(wraps, 0);
|
||||
// The lines span the frame, through the bar.
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert!(
|
||||
lay.lines.iter().all(|l| l.avail.0 <= 10.0 + 1e-6 && l.avail.1 >= 90.0 - 1e-6),
|
||||
"{:?}",
|
||||
lay.lines.iter().map(|l| l.avail).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// A text object that is itself a wrap object keeps its options when its type is made from the
|
||||
/// text document.
|
||||
#[test]
|
||||
fn a_wrapped_text_object_stays_a_wrap_object() {
|
||||
let doc = document_of(
|
||||
&[area_frame(10.0, 10.0, 90.0, 90.0), point_frame(45.0, 50.0)],
|
||||
&[story(WRAP_STORY, &[0], "/0 0", "/1 10.0"), story("Mid\\r", &[1], "/0 0", "/1 10.0")],
|
||||
);
|
||||
let dict = "%_/ArtDictionary :\n%_0 /Bool (AI11TextWrapInvert) ,\n%_4 /Real (AI11TextWrapOffset) ,\n%_;\n%_\n";
|
||||
let layers = layer("Art", true, &format!("{}{}{dict}", frame_object(0, 0), frame_object(1, 0)));
|
||||
let (d, notes) = crate::ai_alone(&private(&native_with_text(&layers, &doc))).unwrap();
|
||||
let kids = d.layers[0].children().unwrap();
|
||||
assert_eq!(kids.len(), 2, "{notes:?}");
|
||||
assert_eq!(kids[1].extras().wrap, Some(vectorcraft_doc::TextWrap { offset: 4.0, invert: false }), "{notes:?}");
|
||||
let NodeKind::Text(area) = &kids[0].kind else { panic!("area type") };
|
||||
assert_eq!(area.wrap.len(), 1, "the area type wraps around the text object above it");
|
||||
}
|
||||
|
||||
/// A Large Canvas editing copy is written at 1/N scale: the wrap offset is scaled with the art.
|
||||
#[test]
|
||||
fn wrap_offset_on_a_large_canvas() {
|
||||
let dict = "%_/ArtDictionary :\n%_0.5 /Real (AI11TextWrapOffset) ,\n%_0 /Bool (AI11TextWrapInvert) ,\n%_;\n%_\n";
|
||||
let art = native_large_canvas(10.0, 10.0, &layer("Art", true, &format!("0 0 1 0 k\n5 5 m\n5.4 5 L\n5.4 5.4 L\n5 5.4 L\nf\n{dict}")));
|
||||
let r = import(&eps(&page_square(50, 50), &art)).unwrap();
|
||||
let kids = r.document.layers[0].children().unwrap();
|
||||
assert_eq!(kids[0].extras().wrap.map(|w| w.offset), Some(5.0), "{:?}", r.warnings);
|
||||
// A `.ai`'s art is scaled when its page is read in its units: the offset with it.
|
||||
let art = native_large_canvas(100.0, 10.0, &layer("Art", true, &format!("{}\n{dict}", square(10, 10))));
|
||||
let (d, w, read) = crate::layered_ai(&private(&art), pdf_part(10.0, 86.0), vec![], false, false, 1.0);
|
||||
assert!(read, "{w:?}");
|
||||
assert_eq!(d.layers[0].children().unwrap()[0].extras().wrap.map(|w| w.offset), Some(0.5), "{w:?}");
|
||||
}
|
||||
|
||||
/// Replacing a group containing either threaded frame must use the PDF page instead.
|
||||
#[test]
|
||||
fn unread_group_holding_threaded_frame_falls_back() {
|
||||
|
||||
@@ -591,6 +591,31 @@ fn frames_and_wrap_shapes_beyond_a_billion_points_are_left_out() {
|
||||
assert_eq!(line_places(&wrapped(Rect::new(150.0, 0.0, 300.0, f64::NAN).to_path(0.1))), line_places(&unwrapped));
|
||||
}
|
||||
|
||||
/// Area type whose frame a wrap object covers sets no line and overflows. Area type wrapped around
|
||||
/// a shape of 10,000 anchors sets its lines beside the shape.
|
||||
#[test]
|
||||
fn a_frame_a_wrap_object_covers_overflows_and_a_complex_wrap_shape_is_laid_out() {
|
||||
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 300.0, 200.0), Justify::Left);
|
||||
let mut wrapped = |path: BezPath| {
|
||||
t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&path), wrap: Default::default() }];
|
||||
layout_soon(&t)
|
||||
};
|
||||
let covered = wrapped(Rect::new(-10.0, -10.0, 310.0, 210.0).to_path(0.1));
|
||||
assert!(covered.lines.is_empty() && covered.overflow, "{:?}", line_places(&covered));
|
||||
// A zigzag between x = 150 and 160 down the frame, closed along its right half.
|
||||
let mut zigzag = BezPath::new();
|
||||
zigzag.move_to((150.0, 0.0));
|
||||
for i in 1..10_000 {
|
||||
zigzag.line_to((if i % 2 == 0 { 150.0 } else { 160.0 }, 200.0 * i as f64 / 10_000.0));
|
||||
}
|
||||
zigzag.line_to((300.0, 200.0));
|
||||
zigzag.line_to((300.0, 0.0));
|
||||
zigzag.close_path();
|
||||
let beside = wrapped(zigzag);
|
||||
assert!(!beside.lines.is_empty(), "no line was set");
|
||||
assert!(beside.lines.iter().all(|li| li.x1 <= 160.0), "{:?}", line_places(&beside));
|
||||
}
|
||||
|
||||
/// Layout steps a line down a frame (or a row or column of one) at most 100,000 times to find room
|
||||
/// for it: in a frame taller than 100,000 leads, a step is a 100,000th of the frame's height.
|
||||
#[test]
|
||||
|
||||
@@ -4810,6 +4810,8 @@ digits Malayalam Malaiàlam
|
||||
@msg `{to}` is not an available name `{to}` no és un nom disponible
|
||||
@msg `{what}` closes nothing `{what}` no tanca res
|
||||
@msg `{what}` closes something it didn't open `{what}` tanca una cosa que no havia obert
|
||||
@msg `{w}` takes no operands `{w}` no admet operands
|
||||
@msg a wraparound group is inside a compound path un grup d’ajustament del text és dins d’un traçat compost
|
||||
@msg a bitmap is empty un mapa de bits és buit
|
||||
@msg a bitmap is too large to read un mapa de bits és massa gran per llegir-lo
|
||||
@msg a bristle brush name can have at most {max} characters: `{name}` has {count} el nom d’un pinzell de cerres pot tenir com a màxim {max} caràcters: `{name}` en té {count}
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4868,6 +4868,8 @@ imageTrace Outline Obrys
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead nejde o logo s plnými barvami ({_1} % jeho plných ploch odpovídá několika plným barvám), takže režim Ploché logo by z jeho přechodů udělal skvrny: použijte místo toho režim Barva
|
||||
@msg `{what}` closes nothing `{what}` nic neuzavírá
|
||||
@msg `{what}` closes something it didn't open `{what}` uzavírá něco, co neotevřel
|
||||
@msg `{w}` takes no operands `{w}` nepřijímá žádné operandy
|
||||
@msg a wraparound group is inside a compound path skupina obtékání textu je uvnitř složené cesty
|
||||
@msg a path has too many points cesta má příliš mnoho bodů
|
||||
@msg it ends inside a layer končí uvnitř vrstvy
|
||||
@msg it has art outside its layers má kresbu mimo své vrstvy
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4477,6 +4477,8 @@ theme Appearance Erscheinungsbild
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead dies ist kein Logo aus flächigen Farben ({_1} % seiner einfarbigen Bereiche entsprechen wenigen flächigen Farben), daher würde der Modus „Flaches Logo“ seine Verläufe in Flecken zerlegen: Verwenden Sie stattdessen den Modus „Farbe“
|
||||
@msg `{what}` closes nothing `{what}` schließt nichts
|
||||
@msg `{what}` closes something it didn't open `{what}` schließt etwas, das es nicht geöffnet hat
|
||||
@msg `{w}` takes no operands `{w}` nimmt keine Operanden
|
||||
@msg a wraparound group is inside a compound path eine Textumfluss-Gruppe liegt in einem zusammengesetzten Pfad
|
||||
@msg a path has too many points ein Pfad hat zu viele Punkte
|
||||
@msg it ends inside a layer es endet innerhalb einer Ebene
|
||||
@msg it has art outside its layers es enthält Grafik außerhalb seiner Ebenen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4510,6 +4510,8 @@ digits Malayalam Μαλαγιάλαμ
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead αυτό δεν είναι λογότυπο επίπεδων χρωμάτων ({_1}% των συμπαγών περιοχών του αντιστοιχούν σε λίγα χρώματα), επομένως η λειτουργία Επίπεδο λογότυπο θα μετατρέψει τα ντεγκραντέ σε κηλίδες: χρησιμοποιήστε τη λειτουργία Χρώμα
|
||||
@msg `{what}` closes nothing το `{what}` δεν κλείνει κανένα στοιχείο
|
||||
@msg `{what}` closes something it didn't open το `{what}` κλείνει στοιχείο που δεν άνοιξε
|
||||
@msg `{w}` takes no operands το `{w}` δεν δέχεται τελεστέους
|
||||
@msg a wraparound group is inside a compound path μια ομάδα αναδίπλωσης κειμένου βρίσκεται μέσα σε σύνθετη διαδρομή
|
||||
@msg a path has too many points η διαδρομή έχει υπερβολικά πολλά σημεία
|
||||
@msg it ends inside a layer τερματίζει μέσα σε επίπεδο
|
||||
@msg it has art outside its layers περιέχει σχέδιο εκτός των επιπέδων του
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 164 and column 5.
|
@@ -4562,6 +4562,8 @@ axis Both Ambos
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead esto no es un logotipo de colores planos ({_1}% de sus zonas sólidas coinciden con unos pocos colores planos), así que el modo Logotipo plano convertiría sus degradados en manchas: usa el modo Color
|
||||
@msg `{what}` closes nothing `{what}` no cierra nada
|
||||
@msg `{what}` closes something it didn't open `{what}` cierra algo que no abrió
|
||||
@msg `{w}` takes no operands `{w}` no admite operandos
|
||||
@msg a wraparound group is inside a compound path un grupo de ceñir texto está dentro de un trazado compuesto
|
||||
@msg a path has too many points un trazado tiene demasiados puntos
|
||||
@msg it ends inside a layer terminan dentro de una capa
|
||||
@msg it has art outside its layers tienen ilustraciones fuera de sus capas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4770,6 +4770,8 @@ imageTrace Outline Ääriviiva
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead tämä ei ole tasavärinen logo ({_1} % sen yhtenäisistä alueista vastaa muutamaa tasaväriä), joten Tasavärilogo-tila muuttaisi sen liukuvärit laikuiksi: käytä sen sijaan Väri-tilaa
|
||||
@msg `{what}` closes nothing `{what}` ei sulje mitään
|
||||
@msg `{what}` closes something it didn't open `{what}` sulkee jotain, mitä se ei avannut
|
||||
@msg `{w}` takes no operands `{w}` ei ota operandeja
|
||||
@msg a wraparound group is inside a compound path tekstin rivitysryhmä on yhdistelmäpolun sisällä
|
||||
@msg a path has too many points polulla on liian monta pistettä
|
||||
@msg it ends inside a layer se päättyy tason sisään
|
||||
@msg it has art outside its layers siinä on kuvitusta tasojensa ulkopuolella
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4552,6 +4552,8 @@ axis Both Les deux
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ce n’est pas un logo en aplats ({_1} % de ses zones pleines correspondent à quelques couleurs unies), donc le mode Logo en aplats transformerait ses dégradés en taches : utilisez le mode Couleur
|
||||
@msg `{what}` closes nothing `{what}` ne ferme rien
|
||||
@msg `{what}` closes something it didn't open `{what}` ferme quelque chose qu’il n’a pas ouvert
|
||||
@msg `{w}` takes no operands `{w}` n’accepte aucun opérande
|
||||
@msg a wraparound group is inside a compound path un groupe d’habillage de texte se trouve dans un tracé transparent
|
||||
@msg a path has too many points un tracé a trop de points
|
||||
@msg it ends inside a layer elles se terminent à l’intérieur d’un calque
|
||||
@msg it has art outside its layers elles contiennent des illustrations hors de leurs calques
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4553,6 +4553,8 @@ imageTrace Outline Körvonal
|
||||
@msg `{to}` is not an available name ez a név nem használható: `{to}`
|
||||
@msg `{what}` closes nothing `{what}` semmit sem zár le
|
||||
@msg `{what}` closes something it didn't open `{what}` olyat zár le, amit nem nyitott meg
|
||||
@msg `{w}` takes no operands `{w}` nem fogad operandusokat
|
||||
@msg a wraparound group is inside a compound path egy szövegkörbefuttatási csoport összetett útvonalon belül van
|
||||
@msg a bitmap is empty egy bitkép üres
|
||||
@msg a bitmap is too large to read egy bitkép túl nagy a beolvasáshoz
|
||||
@msg A browser with WebGPU or WebGL2 is required. WebGPU-t vagy WebGL2-t támogató böngésző szükséges.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4603,6 +4603,8 @@ axis Both Entrambi
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead questo non è un logo a colori piatti ({_1}% delle sue aree piene corrisponde a pochi colori piatti), quindi la modalità Logo piatto trasformerebbe i suoi sfumati in macchie: usa la modalità Colore
|
||||
@msg `{what}` closes nothing `{what}` non chiude nulla
|
||||
@msg `{what}` closes something it didn't open `{what}` chiude qualcosa che non ha aperto
|
||||
@msg `{w}` takes no operands `{w}` non accetta operandi
|
||||
@msg a wraparound group is inside a compound path un gruppo di testo attorno si trova in un tracciato composto
|
||||
@msg a path has too many points un tracciato ha troppi punti
|
||||
@msg it ends inside a layer terminano all'interno di un livello
|
||||
@msg it has art outside its layers contengono grafica al di fuori dei loro livelli
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4307,6 +4307,8 @@ swatch Bright Violet ビビッドバイオレット
|
||||
@msg unknown command `{0}`; did you mean `{1}`? `{0}` は不明なコマンドです。`{1}` のことですか?
|
||||
@msg `{what}` closes nothing `{what}` に対応する開始部分がありません
|
||||
@msg `{what}` closes something it didn't open `{what}` が対応しない開始部分を閉じています
|
||||
@msg `{w}` takes no operands `{w}` はオペランドを取りません
|
||||
@msg a wraparound group is inside a compound path テキストの回り込みグループが複合パスの中にあります
|
||||
@msg a path has too many points ポイントが多すぎるパスがあります
|
||||
@msg it ends inside a layer レイヤーの途中で終わっています
|
||||
@msg it has art outside its layers レイヤーの外にアートがあります
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4302,6 +4302,8 @@ font-technology Variable ცვლადი
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead ეს ერთფეროვანი ლოგო არ არის (მისი მყარი არეების {_1}% რამდენიმე ერთგვაროვან ფერს ემთხვევა), ამიტომ Flat Logo რეჟიმი მის გრადიენტებს ლაქებად აქცევდა: გამოიყენეთ Color რეჟიმი
|
||||
@msg `{what}` closes nothing `{what}` არაფერს ხურავს
|
||||
@msg `{what}` closes something it didn't open `{what}` ხურავს იმას, რაც არ გაუხსნია
|
||||
@msg `{w}` takes no operands `{w}` ოპერანდებს არ იღებს
|
||||
@msg a wraparound group is inside a compound path ტექსტის გარსშემოვლების ჯგუფი შედგენილი ტრაექტორიის შიგნითაა
|
||||
@msg a path has too many points ტრაექტორიას ზედმეტად ბევრი წერტილი აქვს
|
||||
@msg it ends inside a layer ის შრის შიგნით მთავრდება
|
||||
@msg it has art outside its layers მას შრეების გარეთ ნახატი აქვს
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -4234,6 +4234,8 @@ theme Appearance 외관
|
||||
@msg unknown command `{0}`; did you mean `{1}`? 알 수 없는 명령 `{0}`: `{1}`을(를) 의미하셨습니까?
|
||||
@msg `{what}` closes nothing `{what}`이(가) 닫는 항목이 없습니다
|
||||
@msg `{what}` closes something it didn't open `{what}`이(가) 열지 않은 항목을 닫습니다
|
||||
@msg `{w}` takes no operands `{w}`은(는) 피연산자를 받지 않습니다
|
||||
@msg a wraparound group is inside a compound path 문자 감싸기 그룹이 컴파운드 패스 안에 있습니다
|
||||
@msg a path has too many points 패스의 점이 너무 많습니다
|
||||
@msg it ends inside a layer 레이어 안에서 끝납니다
|
||||
@msg it has art outside its layers 레이어 밖에 아트웍이 있습니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4694,6 +4694,8 @@ imageTrace Outline Kontur
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead to nie jest logo z płaskimi kolorami ({_1}% jego jednolitych obszarów pasuje do kilku płaskich kolorów), więc tryb Płaskie logo zamieniłby jego gradienty w plamy: użyj trybu Kolor
|
||||
@msg `{what}` closes nothing `{what}` niczego nie zamyka
|
||||
@msg `{what}` closes something it didn't open `{what}` zamyka coś, czego nie otworzył
|
||||
@msg `{w}` takes no operands `{w}` nie przyjmuje operandów
|
||||
@msg a wraparound group is inside a compound path grupa oblewania tekstem jest wewnątrz ścieżki złożonej
|
||||
@msg a path has too many points ścieżka ma zbyt wiele punktów
|
||||
@msg it ends inside a layer kończy się wewnątrz warstwy
|
||||
@msg it has art outside its layers ma kompozycję poza warstwami
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4148,6 +4148,8 @@ axis Both Ambos
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead este não é um logotipo de cores chapadas ({_1}% de suas áreas sólidas correspondem a poucas cores chapadas), então o modo Logotipo plano transformaria seus gradientes em manchas: use o modo Cor
|
||||
@msg `{what}` closes nothing `{what}` não fecha nada
|
||||
@msg `{what}` closes something it didn't open `{what}` fecha algo que não abriu
|
||||
@msg `{w}` takes no operands `{w}` não aceita operandos
|
||||
@msg a wraparound group is inside a compound path um grupo de quebra de texto está dentro de um traçado composto
|
||||
@msg a path has too many points um traçado tem pontos demais
|
||||
@msg it ends inside a layer termina dentro de uma camada
|
||||
@msg it has art outside its layers tem arte fora de suas camadas
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4598,6 +4598,8 @@ axis Both Оба
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead это не логотип из плоских цветов ({_1}% его сплошных областей совпадает с несколькими плоскими цветами), поэтому режим «Плоский логотип» превратил бы градиенты в пятна: используйте режим «Цвет»
|
||||
@msg `{what}` closes nothing `{what}` ничего не закрывает
|
||||
@msg `{what}` closes something it didn't open `{what}` закрывает то, что не открывал
|
||||
@msg `{w}` takes no operands `{w}` не принимает операндов
|
||||
@msg a wraparound group is inside a compound path группа обтекания текстом находится внутри составного контура
|
||||
@msg a path has too many points в контуре слишком много точек
|
||||
@msg it ends inside a layer они заканчиваются внутри слоя
|
||||
@msg it has art outside its layers в них есть графика вне слоёв
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4461,6 +4461,8 @@ axis Both Båda
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead det här är ingen logotyp i platta färger ({_1} % av dess enfärgade ytor matchar några få platta färger), så läget Platt logotyp skulle göra om dess gradienter till fläckar: använd läget Färg i stället
|
||||
@msg `{what}` closes nothing `{what}` stänger ingenting
|
||||
@msg `{what}` closes something it didn't open `{what}` stänger något som den inte öppnade
|
||||
@msg `{w}` takes no operands `{w}` tar inga operander
|
||||
@msg a wraparound group is inside a compound path en textombrytningsgrupp ligger inuti en sammansatt bana
|
||||
@msg a path has too many points en bana har för många punkter
|
||||
@msg it ends inside a layer den slutar inuti ett lager
|
||||
@msg it has art outside its layers den har grafik utanför sina lager
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -4299,6 +4299,8 @@ axis Both Her İkisi
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead bu düz renkli bir logo değildir (düz alanların %{_1}'si birkaç düz renkle eşleşir), dolayısıyla Düz Logo modu degradelerini yamalara dönüştürür: bunun yerine Renk modunu kullanın
|
||||
@msg `{what}` closes nothing `{what}` hiçbir şeyi kapatmaz
|
||||
@msg `{what}` closes something it didn't open `{what}` açmadığı bir şeyi kapatıyor
|
||||
@msg `{w}` takes no operands `{w}` işlenen almaz
|
||||
@msg a wraparound group is inside a compound path bir metin kaydırma grubu bileşik bir yolun içinde
|
||||
@msg a path has too many points bir yolun çok fazla noktası var
|
||||
@msg it ends inside a layer bir katmanın içinde bitiyor
|
||||
@msg it has art outside its layers katmanlarının dışında sanat var
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -4587,6 +4587,8 @@ axis Both Обидві
|
||||
@msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead це не логотип із плоских кольорів ({_1}% його суцільних областей збігається з кількома плоскими кольорами), тому режим «Плоский логотип» перетворив би градієнти на плями: використовуйте режим «Колір»
|
||||
@msg `{what}` closes nothing `{what}` нічого не закриває
|
||||
@msg `{what}` closes something it didn't open `{what}` закриває те, що не відкривав
|
||||
@msg `{w}` takes no operands `{w}` не приймає операндів
|
||||
@msg a wraparound group is inside a compound path група обтікання текстом розташована всередині складеного контуру
|
||||
@msg a path has too many points у контурі занадто багато точок
|
||||
@msg it ends inside a layer вони закінчуються всередині шару
|
||||
@msg it has art outside its layers у них є графіка поза шарами
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 178 and column 5.
|
@@ -4205,6 +4205,8 @@ theme Appearance Giao diện
|
||||
@msg `{to}` is not an available name `{to}` không phải là tên khả dụng
|
||||
@msg `{what}` closes nothing `{what}` không đóng gì
|
||||
@msg `{what}` closes something it didn't open `{what}` đóng thứ nó chưa mở
|
||||
@msg `{w}` takes no operands `{w}` không nhận toán hạng
|
||||
@msg a wraparound group is inside a compound path một nhóm bao văn bản nằm trong một đường dẫn phức
|
||||
@msg a bitmap is empty ảnh bitmap trống
|
||||
@msg a bitmap is too large to read ảnh bitmap quá lớn để đọc
|
||||
@msg A browser with WebGPU or WebGL2 is required. Cần trình duyệt hỗ trợ WebGPU hoặc WebGL2.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 200 and column 5.
|
@@ -4032,6 +4032,8 @@ imageTrace Outline 轮廓
|
||||
@msg unknown command `{0}`; did you mean `{1}`? 未知命令 `{0}`;您是否想输入 `{1}`?
|
||||
@msg `{what}` closes nothing `{what}` 没有可结束的内容
|
||||
@msg `{what}` closes something it didn't open `{what}` 结束了并非由它开始的内容
|
||||
@msg `{w}` takes no operands `{w}` 不接受操作数
|
||||
@msg a wraparound group is inside a compound path 文本绕排组位于复合路径内
|
||||
@msg a path has too many points 某条路径的点过多
|
||||
@msg it ends inside a layer 它在图层内部结束
|
||||
@msg it has art outside its layers 它有位于图层之外的图稿
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1958,6 +1958,8 @@ file-filter Templates 範本
|
||||
@msg unknown command `{0}`; did you mean `{1}`? 未知的指令 `{0}`;您是指 `{1}` 嗎?
|
||||
@msg `{what}` closes nothing `{what}` 沒有可結束的內容
|
||||
@msg `{what}` closes something it didn't open `{what}` 結束了並非由它開始的內容
|
||||
@msg `{w}` takes no operands `{w}` 不接受運算元
|
||||
@msg a wraparound group is inside a compound path 文字繞圖群組位於複合路徑內
|
||||
@msg a path has too many points 有一條路徑的點過多
|
||||
@msg it ends inside a layer 它在圖層內部結束
|
||||
@msg it has art outside its layers 它有位於圖層之外的圖稿
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user