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:
BFlat
2026-11-02 12:27:55 +07:00
committed by GitHub
37 changed files with 807 additions and 100 deletions
+2
View File
@@ -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`.
///
+142
View File
@@ -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]
+2 -88
View File
@@ -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);
}
}
+32 -1
View File
@@ -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))?;
+3 -1
View File
@@ -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`.
+16 -7
View File
@@ -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);
+24 -1
View File
@@ -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) {
+10 -1
View File
@@ -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() {
+3
View File
@@ -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,
+125
View File
@@ -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`).
+176 -1
View File
@@ -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() {
+46
View File
@@ -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(_))));
}
}
+103
View File
@@ -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() {
+25
View File
@@ -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]
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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.
+2
View File
@@ -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