From aed9898b8dc79086408cb828db7d0c013e65c196 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 5 Nov 2026 04:26:00 -0800 Subject: [PATCH] .ai save: the editing copy keeps type as type The editing copy of a PDF-compatible .ai wrote every text object as its outlines. Point type, area type and type on a path are now written as text: a text object in the layers that names its story, and the text document after the document data (%AI11_BeginTextDocument) holding the stories. - Each story holds its characters (a paragraph ends in \r, a forced line break is \x03), its character runs (font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill and stroke with its weight, capitals, Tate-chu-yoko) and its paragraph runs (alignment, indents, space before and after, hyphenation, right-to-left direction). - Point type keeps its point and, when it is turned, its matrix. Type scaled by one factor, such as all type on a large canvas, has its scale in its sizes, frame and path. Area type keeps its frame, rows, columns, gutters and first baseline, type on a path its path and where it starts and ends, and vertical type its orientation. A threaded story is one story through its frames in order. Area type and type on a path carry their frame's path in the layers as well. - Type the text document can't hold is written as its outlines, with a warning: type in opacity masks, pattern tiles and art cut to a clipping object, mirrored type, turned or unevenly scaled area type and type on a path, frames of several parts, area type that flows around a text wrap, threads whose frames differ in orientation or include a frame written as outlines, type and stories past the sizes the reader reads, and type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own. Other character and paragraph attributes are left out, with a warning. Tracking is written as a whole number of thousandths of an em, and solid paints as simple paints; tracking that isn't a whole number is rounded, with the warning. Each construct is written in the form VectorCraft's .ai reader reads (crates/eps/src/import/ate.rs at 36ef34dfdd: Texts::read :407, story :501, frame :456, para :562, style :588, paint :622, AreaFrame::read :284; the text object in import/ai/read/art.rs:15 and its path in import/ai/frames.rs:26), and the tests read the written data back through that reader. The scripting reference documents the same attributes on the reading side (text frame kinds, columnCount, columnGutter, rowCount, startTValue, endTValue, orientation, and the character attributes): https://web.archive.org/web/20160410101607/https://wwwimages.adobe.com/content/dam/Adobe/en/devnet/illustrator/sdk/CC2014/Illustrator%20Scripting%20Reference%20-%20JavaScript.pdf The reader now closes a text object's frame path painted with a closing operator (n, f, s, b, h), so area type's frame comes back closed; places point type whose story keeps no composed lines at its frame's point; keeps a story's last empty paragraph (only the \r that ends the story is dropped); gives type made from the text document its text object's lock, as it gives it its visibility; and gives type its text object's knockout shape flag, as it gives it its opacity and blend mode. Tests: the round trip of every kind of type and its state, a second write of what is read back giving the same bytes, the text document and stubs, scaled point type, a story split between threaded frames, the fallbacks and the warnings, and the engine's editing copy with every save option; a fuzz case for damaged text documents and stubs. Docs: docs/file-format-parity.md. --- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 59 + crates/engine/tests/import_fuzz.rs | 31 + crates/eps/src/ai_editing.rs | 16 +- crates/eps/src/ai_editing/tests.rs | 790 ++++++++++++- crates/eps/src/ai_editing/writer.rs | 51 +- crates/eps/src/ai_editing/writer/texts.rs | 1013 +++++++++++++++++ crates/eps/src/import.rs | 3 +- crates/eps/src/import/ai/frames.rs | 23 +- crates/eps/src/import/ai/read/art.rs | 1 + crates/eps/src/import/ate.rs | 32 +- crates/eps/src/import/native.rs | 1 + crates/eps/src/import/native/slots.rs | 19 +- crates/eps/src/import/tests_native.rs | 35 + docs/file-format-parity.md | 11 +- 14 files changed, 2057 insertions(+), 28 deletions(-) create mode 100644 crates/eps/src/ai_editing/writer/texts.rs diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index b87ec3ee2..96d040dc3 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -803,6 +803,65 @@ fn images_come_back_from_the_editing_copy() { } } +/// The type of `doc`'s art, one line each: its kind, point type's matrix or area type's frame on the +/// document (rounded to hundredths), its characters, and each run's font, size and fill. +fn type_outline(doc: &vectorcraft_doc::Document) -> Vec { + use vectorcraft_doc::{NodeKind, TextKind}; + let mut out = vec![]; + let round = |v: f64| (v * 100.0).round() / 100.0 + 0.0; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + let (kind, xf) = match &t.kind { + TextKind::Point => ("point", t.xf.as_coeffs().map(round).to_vec()), + TextKind::Area { frame } => { + let b = frame.transformed(t.xf).bounds().unwrap(); + ("area", vec![round(b.x0), round(b.y0), round(b.x1), round(b.y1)]) + } + TextKind::OnPath { .. } => ("path", vec![]), + }; + let runs: Vec = t + .runs + .iter() + .map(|r| format!("{:?} {} {} {} {:?}", r.text, r.style.font_family, r.style.font_style, round(r.style.size), r.style.fill)) + .collect(); + out.push(format!("{kind} {xf:?} {runs:?}")); + } + }); + out +} + +/// Point type of two paragraphs, turned point type and area type come back from the editing copy +/// for other apps alone as type, with every save option that writes the copy. +#[test] +fn type_comes_back_from_the_editing_copy() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("text.create", &json!({"x": 20, "y": 40, "text": "Hello\nWorld", "size": 24})).unwrap(); + s.execute("text.create", &json!({"x": 150, "y": 100, "text": "Turned"})).unwrap(); + // The new type is selected. + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("text.create", &json!({"x": 20, "y": 120, "text": "Area type in a frame", "area": {"width": 120, "height": 60}})).unwrap(); + let path = tmp("type.ai"); + s.execute("document.save", &json!({"path": path})).unwrap(); + let before = type_outline(&s.doc().unwrap().doc); + assert_eq!(before.len(), 3, "{before:#?}"); + for opts in [ + json!({"format": "ai"}), + json!({"format": "ai", "compress": false}), + json!({"format": "ai", "embedProfiles": false}), + json!({"format": "ai", "includeLinked": true}), + json!({"format": "ai", "range": "all"}), + json!({"format": "ai", "pdfCompatible": false}), + ] { + let r = s.execute("document.export", &opts).unwrap(); + assert!(!r["warnings"].to_string().contains("type"), "{opts} {}", r["warnings"]); + let copy = load::load_with(&path, &b64(&r), &LoadOptions { native: false, ..Default::default() }).unwrap(); + assert!(copy.editing_data && !copy.restored, "{opts} {:?}", copy.warnings); + assert!(!copy.warnings.iter().any(|w| w.contains("editing data")), "{opts} {:?}", copy.warnings); + assert_eq!(type_outline(©.doc), before, "{opts}"); + } +} + /// A document of two artboards (the art on the first): a rectangle filled with a linear gradient of /// three stops placed across part of it, an ellipse filled with an elliptical radial gradient whose /// focal point is off its center, a rectangle stroked with a gradient, rectangles filled and diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 57749df2a..d7c473bfc 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3413,6 +3413,37 @@ proptest! { survive("hostile .ai images and placed files", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// A `.ai` whose type (point type turned, area type and type on a path, their stubs with the + /// frames' paths, and the text document they name, as the `.ai` writer writes them) is damaged: + /// read from its editing data alone and beside a page. + #[test] + fn ai_text_documents_never_panic( + cut in 0usize..30_000, + edits in prop::collection::vec((0usize..30_000, prop::sample::select(vec!['0', '1', '9', ' ', '\n', '(', ')', '[', ']', '<', '>', '/', '%', '_', 'n', 'f', 'z', '~'])), 0..16), + ) { + use vectorcraft_doc::{CharStyle, Node, NodeKind, TextKind, TextObject}; + use vectorcraft_geom::{Affine, Point, Rect, shapes}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut point = TextObject::point(Point::new(10.0, 30.0), "Turned\ntype", CharStyle::default()); + point.xf *= Affine::rotate(0.4); + let mut area = TextObject::point(Point::ZERO, "Area type", CharStyle::default()); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 40.0, 90.0, 90.0)) }; + area.xf = Affine::IDENTITY; + let mut on_path = TextObject::point(Point::ZERO, "On a path", CharStyle::default()); + on_path.kind = TextKind::OnPath { path: shapes::ellipse(Rect::new(100.0, 20.0, 180.0, 80.0)), start: 0.25, end: None }; + on_path.xf = Affine::IDENTITY; + for t in [point, area, on_path] { + let n = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let program = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap().program; + let text = mutate_text(&String::from_utf8_lossy(&program), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("hostile .ai type", || vectorcraft_eps::ai_alone(&private).ok().map(|r| r.0))?; + survive("hostile .ai type beside a page", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose variable library is damaged or hostile: the library is counted for the warning /// whether the layers are read or not (an inserted `Z` can make an operator the reader doesn't /// handle, and then the layers aren't read). diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index b6ea72eea..2ad1c719c 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -78,12 +78,23 @@ //! (`w h rowBytes 32 17 `, then rows of premultiplied ARGB), each in versioned content, `~`, //! `%AI5_EndPlace`. The box is the file's pixels at art space's origin on the canvas; the matrix //! takes it into art space. In opacity masks and pattern tiles, linked images are embedded. +//! - Type: a text object `/AI11Text :` naming its story and frame (`/StoryIndex`, `/FrameIndex`), +//! for area type and type on a path with its frame's path (`/Art :` … `; /ConfiningPath ,`), and +//! the text document after the document data (`%AI11_BeginTextDocument`, see +//! `writer::texts`), in the form the reader's `ate.rs` reads: the characters, the fonts, sizes, +//! leading, tracking, baseline shift, scales, solid fills and strokes and capitals of each run, +//! the alignment, indents, spacing, hyphenation and direction of each paragraph, and the frame: +//! point type's point and matrix, area type's frame, rows, columns, gutters and first baseline, +//! type on a path's path and span, vertical type, and the frames a threaded story flows through. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the //! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and //! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such -//! loss a warning: type as its outlines and its inline graphics' art, freeform gradients, gradients +//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles and art cut to +//! a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby, +//! gradient or pattern paint, an appearance of its own) as its outlines and its inline graphics' +//! art, the character and paragraph attributes the text document doesn't hold, freeform gradients, gradients //! along or across a stroke and gradients without stops, with more than 256 stops or without extent //! as their average color //! (gradients that dither or mix perceptually are written as classic ones, and gradient stops linked @@ -225,6 +236,9 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Document { }; let im = Node::new(doc.alloc_id(), NodeKind::Image(Box::new(im))); doc.insert(Some(layer), usize::MAX, im).unwrap(); + // Type the text document can't hold (a gradient fill). + let shade = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))]); let t = vectorcraft_doc::TextObject::point( vectorcraft_geom::Point::new(10.0, 70.0), "Type", - vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }, + vectorcraft_doc::CharStyle { + size: 18.0, + fill: Paint::Gradient(Box::new(GradientPaint::new(shade))), + ..vectorcraft_doc::CharStyle::default() + }, ); let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); doc.insert(Some(layer), usize::MAX, t).unwrap(); @@ -720,7 +726,9 @@ fn brushed_strokes_are_written_as_their_brush_art() { fn type_is_written_as_create_outlines_makes_it() { let mut doc = Document::new(200.0, 100.0); let layer = doc.layers[0].id; - let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), underline: true, ..vectorcraft_doc::CharStyle::default() }; + // A pattern fill: type the text document can't hold. + let pattern = Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }; + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: pattern, underline: true, ..vectorcraft_doc::CharStyle::default() }; let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Under", style); let mut t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); t.name = Some("Title".into()); @@ -3067,3 +3075,781 @@ fn sixteen_bit_cmyk_images_are_written_at_eight_bits() { assert_eq!(out.warnings, [writer::IMAGE_DEPTH]); assert!(program_shown(&doc).contains("/DeviceCMYK XN\n")); } + +// ---------- type ---------- + +fn style(size: f64, fill: Color) -> vectorcraft_doc::CharStyle { + vectorcraft_doc::CharStyle { size, fill: solid(fill), ..vectorcraft_doc::CharStyle::default() } +} + +fn text_node(doc: &mut Document, t: vectorcraft_doc::TextObject) -> Node { + Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))) +} + +/// A 300 × 200 document of the type the slice writes as text, on a layer `Type`: two paragraphs +/// of point type (the second centered, with space before, a fixed leading) named `Greeting`; point +/// type turned 30° in CMYK; area type in two columns with a gutter and a first baseline at its +/// leading, its runs plain, then bold, tracked, shifted, scaled, stroked and in capitals, then a +/// right-to-left paragraph; type on an open curve from a tenth of its length; vertical point type; +/// hidden, locked point type at half opacity multiplied; and area type threaded through two frames. +fn typed() -> Document { + use vectorcraft_doc::{CharStyle, FirstBaseline, Justify, ParaDirection, ParaStyle, TextKind, TextObject, TextRun}; + use vectorcraft_geom::Point; + let mut doc = Document::new(300.0, 200.0); + // One CMYK swatch, not the default ones: their CMYK equivalents round differently once read back. + doc.swatches = vec![ + Swatch { name: "[None]".into(), paint: Paint::None, global: false, spot: false }, + Swatch { name: "Ink".into(), paint: solid(BRAND), global: false, spot: false }, + ] + .into(); + doc.swatch_groups = vec![].into(); + doc.layers.clear(); + let layer = doc.add_layer(Some("Type")); + + let mut greet = TextObject::point(Point::new(20.0, 40.0), "Hello\nWorld", CharStyle { leading: Some(30.0), ..style(24.0, SKY) }); + let centered = ParaStyle { justify: Justify::Center, space_before: 6.0, ..ParaStyle::default() }; + greet.set_paragraph_styles(vec![ParaStyle { first_line_indent: 10.0, ..ParaStyle::default() }, centered]); + let greet = named(text_node(&mut doc, greet), "Greeting"); + doc.insert(Some(layer), usize::MAX, greet).unwrap(); + + let mut turned = TextObject::point(Point::ZERO, "Turned", style(18.0, BRAND)); + turned.xf = Affine::translate((150.0, 100.0)) * Affine::rotate(-std::f64::consts::FRAC_PI_6); + let turned = text_node(&mut doc, turned); + doc.insert(Some(layer), usize::MAX, turned).unwrap(); + + let regular = style(10.0, Color::BLACK); + let bold = CharStyle { + font_style: "Bold".into(), + tracking: 50.0, + baseline_shift: 3.0, + h_scale: 120.0, + v_scale: 90.0, + stroke: solid(Color::rgb(0.0, 0.5, 0.0)), + stroke_width: 0.5, + all_caps: true, + ..regular.clone() + }; + let mut area = TextObject::point(Point::ZERO, "", regular.clone()); + area.runs = vec![ + TextRun::new("Area type flows in two columns ", regular.clone()), + TextRun::new("and wraps", bold), + TextRun::new(" onto the next line.\nRight to left", regular.clone()), + ]; + area.set_paragraph_styles(vec![ + ParaStyle { left_indent: 4.0, right_indent: 2.0, space_after: 3.0, hyphenate: false, ..ParaStyle::default() }, + ParaStyle { direction: Some(ParaDirection::RightToLeft), justify: Justify::JustifyAll, ..ParaStyle::default() }, + ]); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 80.0, 140.0, 160.0)) }; + area.xf = Affine::IDENTITY; + area.area.columns = 2; + area.area.gutter = 12.0; + area.area.first_baseline = FirstBaseline::Leading; + area.area.first_baseline_min = 4.0; + let area = text_node(&mut doc, area); + doc.insert(Some(layer), usize::MAX, area).unwrap(); + + let mut on_path = TextObject::point(Point::ZERO, "On a curve", style(12.0, Color::gray(0.6))); + let mut curve = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((160.0, 150.0).into()), + kurbo::PathEl::CurveTo((190.0, 110.0).into(), (250.0, 110.0).into(), (280.0, 150.0).into()), + ])); + curve.subpaths.iter_mut().for_each(|sp| sp.closed = false); + on_path.kind = TextKind::OnPath { path: curve, start: 0.1, end: None }; + on_path.xf = Affine::IDENTITY; + let on_path = text_node(&mut doc, on_path); + doc.insert(Some(layer), usize::MAX, on_path).unwrap(); + + let mut down = TextObject::point(Point::new(280.0, 20.0), "Down", style(14.0, Color::BLACK)); + down.vertical = true; + let down = text_node(&mut doc, down); + doc.insert(Some(layer), usize::MAX, down).unwrap(); + + let quiet = TextObject::point(Point::new(20.0, 190.0), "Quiet", style(9.0, Color::BLACK)); + let mut quiet = see_through(text_node(&mut doc, quiet), 0.5, BlendMode::Multiply); + quiet.visible = false; + quiet.locked = true; + doc.insert(Some(layer), usize::MAX, quiet).unwrap(); + + let mut first = TextObject::point(Point::ZERO, "A story that runs from one frame into the next one.", regular.clone()); + first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(160.0, 20.0, 220.0, 40.0)) }; + first.xf = Affine::IDENTITY; + let mut second = first.clone(); + second.runs = vec![TextRun::new("", regular)]; + second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(160.0, 50.0, 220.0, 90.0)) }; + let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second)); + doc.text_threads.push(vec![first.id, second.id]); + doc.insert(Some(layer), usize::MAX, first).unwrap(); + doc.insert(Some(layer), usize::MAX, second).unwrap(); + doc +} + +/// What the reader's view of type is made of, one line per text object: its name, state, +/// transparency, kind and frame, matrix, orientation, area options and path span; each paragraph's +/// attributes; each run's characters and attributes. Lengths to three decimals. +fn type_lines(doc: &Document) -> Vec { + use vectorcraft_doc::TextKind; + let mut out = vec![]; + let mut all = vec![]; + paths(&doc.layers, &mut all); + for n in all { + let NodeKind::Text(t) = &n.kind else { + out.push(format!("not type: {:?}", n.name)); + continue; + }; + let pts = |p: &PathData| { + p.subpaths + .iter() + .map(|sp| { + format!("[{} {}]", sp.closed, sp.anchors.iter().map(|a| format!("{},{}", r3(a.p.x), r3(a.p.y))).collect::>().join(" ")) + }) + .collect::() + }; + let kind = match &t.kind { + TextKind::Point => "point".to_string(), + TextKind::Area { frame } => format!("area {}", pts(frame)), + TextKind::OnPath { path, start, end } => format!("path {} {} {:?}", pts(path), r3(*start), end.map(r3)), + }; + let a = &t.area; + out.push(format!( + "{:?} shown={} locked={} {} {:?} {kind} xf={:?} vertical={} area {} {} {} {:?} {:?} {}", + n.name, + n.visible, + n.locked, + r3(f64::from(n.opacity)), + n.blend, + t.xf.as_coeffs().map(r3), + t.vertical, + a.rows, + a.columns, + r3(a.gutter), + a.row_gutter.map(r3), + a.first_baseline, + r3(a.first_baseline_min), + )); + for p in t.paragraph_styles() { + out.push(format!( + " para {:?} {} {} {} {} {} {} {:?}", + p.justify, + r3(p.first_line_indent), + r3(p.left_indent), + r3(p.right_indent), + r3(p.space_before), + r3(p.space_after), + p.hyphenate, + p.direction + )); + } + for r in &t.runs { + let s = &r.style; + out.push(format!( + " run {:?} {} {} {} {:?} {} {} {} {} fill {} stroke {} {} caps={} small={:?} tcy={}", + r.text, + s.font_family, + s.font_style, + r3(s.size), + s.leading.map(r3), + r3(s.tracking), + r3(s.baseline_shift), + r3(s.h_scale), + r3(s.v_scale), + paint(&s.fill), + paint(&s.stroke), + r3(s.stroke_width), + s.all_caps, + s.small_caps.map(r3), + s.tate_chu_yoko, + )); + } + } + out +} + +#[test] +fn type_comes_back_as_type() { + let doc = typed(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&type_lines(&back), &type_lines(&doc)); + assert_eq!(back.text_threads.len(), 1, "the thread comes back"); +} + +#[test] +fn type_is_written_again_as_the_same_bytes() { + let doc = typed(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + let again = export_ai_editing(&read_back(&doc), &opts()).unwrap(); + let text = |p: &[u8]| text_document(&String::from_utf8_lossy(p).replace("\r\n", "\n")); + assert_eq!(text(&again.program), text(&first.program)); + assert_eq!(String::from_utf8_lossy(&again.program), String::from_utf8_lossy(&first.program)); +} + +/// The text document of program `text` (LF line ends), decoded, with its UTF-16 strings' zero +/// bytes left out. +fn text_document(text: &str) -> String { + let Some(start) = text.find("%AI11_BeginTextDocument\n") else { return String::new() }; + let end = text.find("%AI11_EndTextDocument").unwrap(); + let packed: String = text[start..end].lines().skip(2).filter(|l| *l != ";").map(|l| l.strip_prefix('%').unwrap_or(l)).collect(); + String::from_utf8_lossy(&crate::ps::ascii85_decode(&packed).unwrap()).replace('\0', "") +} + +/// The text document: its section after the document data, ASCII85 on comment lines none of which +/// is a `%_` line; a stub for each text object, area type and type on a path with their paths. +#[test] +fn type_is_a_text_document_and_stubs() { + let text = program(&typed()); + let doc_data = text.find("%AI9_EndDocumentData").unwrap(); + let start = text.find("%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n").unwrap(); + let end = text.find(";\n%AI11_EndTextDocument\n%%EndSetup\n").unwrap(); + assert!(doc_data < start && start < end); + let body = &text[start..end]; + for l in body.lines().skip(2) { + assert!(l.starts_with('%') && !l.starts_with("%_"), "{l}"); + } + assert_eq!(text.matches("/AI11Text :\n0 /FreeUndo ,\n").count(), 8); + assert_eq!(text.matches("; /ConfiningPath ,\n").count(), 4); + assert!(text.contains("0 /FrameIndex ,\n6 /StoryIndex ,") && text.contains("1 /FrameIndex ,\n6 /StoryIndex ,"), "{text}"); + // The decoded document: the version, a font of each face and the hidden characters' font, the + // stories, the normal sheets and the document's text settings. + let decoded = text_document(&text); + assert!(decoded.starts_with("/98 << /0 14 >> /0 <<"), "{decoded}"); + for want in + ["SourceSans3-Regular", "SourceSans3-Bold", "AdobeInvisFont", "[Normal Character Style]", "[Normal Paragraph Style]", "Hello\rWorld\r"] + { + assert!(decoded.contains(want), "{want}: {decoded}"); + } + // Tracking is a whole number, and colors are simple paints. + assert!(decoded.contains("/8 50 ") && !decoded.contains("/8 50."), "{decoded}"); + assert!(decoded.contains("/53 << /99 /SimplePaint") && !decoded.contains("CAITextPaint"), "{decoded}"); +} + +/// Point type scaled 150% comes back at its size: its scale is in its sizes, its matrix only turns +/// it. Type that ends in an empty paragraph keeps it. +#[test] +fn scaled_point_type_and_a_last_empty_paragraph_come_back() { + use vectorcraft_doc::{CharStyle, ParaStyle, TextObject}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut big = TextObject::point(vectorcraft_geom::Point::ZERO, "Big", CharStyle { leading: Some(20.0), ..style(12.0, Color::BLACK) }); + big.xf = Affine::translate((20.0, 40.0)) * Affine::rotate(0.3) * Affine::scale(1.5); + big.set_all_paras(ParaStyle { left_indent: 4.0, ..ParaStyle::default() }); + let ends = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "Ends\n", style(12.0, Color::BLACK)); + let (big, ends) = (text_node(&mut doc, big), text_node(&mut doc, ends)); + doc.insert(Some(layer), usize::MAX, big).unwrap(); + doc.insert(Some(layer), usize::MAX, ends).unwrap(); + let back = read_back(&doc); + let texts: Vec<&vectorcraft_doc::TextObject> = + back.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect(); + let [big, ends] = texts.as_slice() else { panic!("two text objects: {texts:?}") }; + let s = big.xf.determinant().sqrt(); + let st = &big.runs[0].style; + assert_eq!((r3(s), r3(st.size), st.leading.map(r3), r3(big.para.left_indent)), (r3(1.0), r3(18.0), Some(r3(30.0)), r3(6.0))); + assert_eq!(big.xf.as_coeffs().map(r3), (Affine::translate((20.0, 40.0)) * Affine::rotate(0.3)).as_coeffs().map(r3)); + assert_eq!(ends.plain_text(), "Ends\n"); + // A tiny size, and a stroke of no width that keeps its paint. + let mut d = Document::new(200.0, 100.0); + let layer = d.layers[0].id; + let st = CharStyle { stroke: solid(Color::rgb(1.0, 0.0, 0.0)), stroke_width: 0.0, ..style(0.00001, Color::BLACK) }; + let n = text_node(&mut d, TextObject::point(vectorcraft_geom::Point::new(20.0, 40.0), "Tiny", st)); + d.insert(Some(layer), usize::MAX, n).unwrap(); + let back = read_back(&d); + let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") }; + let s = &t.runs[0].style; + assert_eq!( + (format!("{:.6}", s.size), paint(&s.stroke), r3(s.stroke_width)), + ("0.000010".into(), paint(&solid(Color::rgb(1.0, 0.0, 0.0))), r3(0.0)) + ); +} + +/// A threaded story whose frames each hold their slice of it, a paragraph split between them, +/// comes back as one story in its first frame with each paragraph's attributes once. +#[test] +fn a_story_split_between_frames_comes_back_whole() { + use vectorcraft_doc::{Justify, ParaStyle, TextKind, TextObject, TextRun}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let st = style(10.0, Color::BLACK); + let (p1, p2, p3) = ( + ParaStyle { justify: Justify::Center, ..ParaStyle::default() }, + ParaStyle { left_indent: 5.0, ..ParaStyle::default() }, + ParaStyle { space_before: 4.0, ..ParaStyle::default() }, + ); + let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "", st.clone()); + first.runs = vec![TextRun::new("One two\nthree ", st.clone())]; + first.set_paragraph_styles(vec![p1.clone(), p2.clone()]); + first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) }; + first.xf = Affine::IDENTITY; + let p4 = ParaStyle { right_indent: 7.0, ..ParaStyle::default() }; + let mut second = first.clone(); + second.runs = vec![TextRun::new("four\nFive\n", st)]; + second.set_paragraph_styles(vec![p2.clone(), p3.clone(), p4.clone()]); + second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 40.0)) }; + let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second)); + doc.text_threads.push(vec![first.id, second.id]); + doc.insert(Some(layer), usize::MAX, first).unwrap(); + doc.insert(Some(layer), usize::MAX, second).unwrap(); + let back = read_back(&doc); + let texts: Vec<&TextObject> = + back.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect(); + let [a, b] = texts.as_slice() else { panic!("two frames: {texts:?}") }; + assert_eq!((a.plain_text().as_str(), b.plain_text().as_str()), ("One two\nthree four\nFive\n", "")); + let attrs = |p: &ParaStyle| (p.justify, r3(p.left_indent), r3(p.right_indent), r3(p.space_before)); + let got: Vec<_> = a.paragraph_styles().iter().map(attrs).collect(); + assert_eq!(got, [&p1, &p2, &p3, &p4].into_iter().map(attrs).collect::>(), "the last, empty paragraph keeps its own"); + assert_eq!(back.text_threads.len(), 1); + assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.is_empty()); + // An attribute the text document doesn't hold in a later frame is reported. + let second_id = doc.text_threads[0][1]; + let mut lossy = doc.clone(); + if let NodeKind::Text(t) = &mut lossy.node_mut(second_id).unwrap().kind { + t.runs[0].style.underline = true; + } + assert_eq!(export_ai_editing(&lossy, &opts()).unwrap().warnings, [writer::TYPE_ATTRIBUTES]); + // So is a paragraph split between the frames whose second part has other attributes. + let mut split = doc.clone(); + if let NodeKind::Text(t) = &mut split.node_mut(second_id).unwrap().kind { + t.set_paragraph_styles(vec![ParaStyle { left_indent: 9.0, ..ParaStyle::default() }, p3, p4]); + } + assert_eq!(export_ai_editing(&split, &opts()).unwrap().warnings, [writer::TYPE_ATTRIBUTES]); +} + +/// A story longer than the reader reads is written as its outlines. +#[test] +fn a_story_longer_than_the_reader_reads_is_written_as_its_outlines() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let long = format!("A{}", " ".repeat(crate::import::MAX_STORY)); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), &long, style(12.0, Color::BLACK)); + let short = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Short", style(12.0, Color::BLACK)); + let (t, short) = (text_node(&mut doc, t), text_node(&mut doc, short)); + doc.insert(Some(layer), usize::MAX, t).unwrap(); + doc.insert(Some(layer), usize::MAX, short).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::TYPE]); + assert_eq!(program(&doc).matches("/AI11Text :").count(), 1, "the short story is text"); +} + +/// Stories that together make a text document larger than the reader reads: the ones past it are +/// written as their outlines, and what is written reads back. +#[test] +fn stories_past_the_text_document_the_reader_reads_are_written_as_their_outlines() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let long = format!("A{}", " ".repeat(900_000)); + for i in 0..9 { + // A font of its own, which no other story names. + let st = vectorcraft_doc::CharStyle { font_family: format!("Probe Family {i}"), ..style(8.0, Color::BLACK) }; + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 10.0 + f64::from(i) * 10.0), &long, st); + let n = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::TYPE]); + let text = String::from_utf8_lossy(&out.program).replace("\r\n", "\n"); + let stubs = text.matches("/AI11Text :").count(); + assert!((1..9).contains(&stubs), "{stubs}"); + // The font set holds the fonts of the stories written, and the hidden characters' font. + assert_eq!(text_document(&text).matches("/CoolTypeFont").count(), stubs + 1); + let back = read_back(&doc); + let texts = back.layers[0].children().unwrap().iter().filter(|n| matches!(n.kind, NodeKind::Text(_))).count(); + assert_eq!(texts, stubs, "every story written comes back"); +} + +/// A thread whose frames differ in orientation is written as its outlines: a story's frames share +/// one. +#[test] +fn a_thread_of_horizontal_and_vertical_frames_is_written_as_its_outlines() { + use vectorcraft_doc::{TextKind, TextObject}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "Across, then down", style(10.0, Color::BLACK)); + first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) }; + first.xf = Affine::IDENTITY; + let mut second = first.clone(); + second.runs[0].text.clear(); + second.vertical = true; + second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 90.0)) }; + let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second)); + doc.text_threads.push(vec![first.id, second.id]); + doc.insert(Some(layer), usize::MAX, first).unwrap(); + doc.insert(Some(layer), usize::MAX, second).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::TYPE]); + assert!(!program(&doc).contains("/AI11Text")); +} + +/// A thread with a frame in art cut to a clipping object that isn't a path is written as its +/// outlines, every frame of it; so is type whose size is past what the reader reads. +#[test] +fn a_thread_with_a_frame_in_cut_art_and_type_past_the_readers_sizes_are_outlines() { + use vectorcraft_doc::{TextKind, TextObject}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "A story in two frames", style(10.0, Color::BLACK)); + first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) }; + first.xf = Affine::IDENTITY; + let mut second = first.clone(); + second.runs[0].text.clear(); + second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 40.0)) }; + let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second)); + doc.text_threads.push(vec![first.id, second.id]); + // The second frame is clipped by type: its art is cut to the type's outlines. + let clipper = text_node(&mut doc, TextObject::point(vectorcraft_geom::Point::new(110.0, 30.0), "CUT", style(30.0, Color::BLACK))); + let cut = group(&mut doc, vec![clipper, second], true); + doc.insert(Some(layer), usize::MAX, first).unwrap(); + doc.insert(Some(layer), usize::MAX, cut).unwrap(); + let huge = TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Huge", style(12.0, Color::BLACK)); + let huge = TextObject { xf: Affine::translate((10.0, 90.0)) * Affine::scale(1e5), ..huge }; + let huge = text_node(&mut doc, huge); + doc.insert(Some(layer), usize::MAX, huge).unwrap(); + let text = program(&doc); + assert!(!text.contains("/AI11Text"), "{text}"); + // Tracking that rounds to the reader's limit, 100,000, is written as outlines. + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut wide = TextObject::point(vectorcraft_geom::Point::new(10.0, 50.0), "Wide", style(12.0, Color::BLACK)); + wide.runs[0].style.tracking = 99_999.6; + let wide = text_node(&mut doc, wide); + doc.insert(Some(layer), usize::MAX, wide).unwrap(); + let text = program(&doc); + assert!(!text.contains("/AI11Text"), "{text}"); + // A frame that is the clipping object of a clipping group is written as the clipping outline. + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "A story in two frames", style(10.0, Color::BLACK)); + first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) }; + first.xf = Affine::IDENTITY; + let mut second = first.clone(); + second.runs[0].text.clear(); + second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 40.0)) }; + let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second)); + doc.text_threads.push(vec![first.id, second.id]); + let backing = square(&mut doc, Rect::new(100.0, 0.0, 200.0, 50.0), Color::rgb(1.0, 0.0, 0.0)); + let clipped = group(&mut doc, vec![second, backing], true); + doc.insert(Some(layer), usize::MAX, first).unwrap(); + doc.insert(Some(layer), usize::MAX, clipped).unwrap(); + let text = program(&doc); + assert!(!text.contains("/AI11Text"), "{text}"); +} + +#[test] +fn type_the_data_cant_hold_is_written_as_its_outlines() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let g = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::rgb(1.0, 0.0, 0.0))]); + let fill = Paint::Gradient(Box::new(GradientPaint::new(g))); + let t = vectorcraft_doc::TextObject::point( + vectorcraft_geom::Point::new(10.0, 70.0), + "Shaded", + vectorcraft_doc::CharStyle { size: 18.0, fill, ..vectorcraft_doc::CharStyle::default() }, + ); + let mirrored = vectorcraft_doc::TextObject { + xf: Affine::new([-1.0, 0.0, 0.0, 1.0, 150.0, 70.0]), + ..vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Mirror", style(18.0, Color::BLACK)) + }; + // Area type in a frame of two subpaths: a frame of the text document is one path. + let mut holed = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Two rings", style(10.0, Color::BLACK)); + let mut rings = shapes::rectangle(Rect::new(100.0, 10.0, 190.0, 90.0)); + rings.subpaths.extend(shapes::rectangle(Rect::new(120.0, 30.0, 170.0, 70.0)).subpaths); + holed.kind = vectorcraft_doc::TextKind::Area { frame: rings }; + holed.xf = Affine::IDENTITY; + // A carriage return in the characters: a story ends its paragraphs with it. Area type that + // flows around a wrap object: the wrap object's settings aren't written. + let returned = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "A\rB", style(10.0, Color::BLACK)); + let mut wrapped = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped round", style(10.0, Color::BLACK)); + wrapped.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)) }; + wrapped.xf = Affine::IDENTITY; + wrapped.wrap = vec![vectorcraft_doc::WrapShape { path: shapes::rectangle(Rect::new(30.0, 20.0, 50.0, 40.0)), wrap: Default::default() }]; + let wrapped = text_node(&mut doc, wrapped); + doc.insert(Some(layer), usize::MAX, wrapped).unwrap(); + // Type on an open path whose ends meet: it would be read back on a closed path. + let mut ring = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Round", style(10.0, Color::BLACK)); + let mut open_ring = shapes::ellipse(Rect::new(120.0, 10.0, 180.0, 70.0)); + if let Some(first) = open_ring.subpaths[0].anchors.first().copied() { + open_ring.subpaths[0].anchors.push(first); + } + open_ring.subpaths[0].closed = false; + ring.kind = vectorcraft_doc::TextKind::OnPath { path: open_ring, start: 0.0, end: None }; + ring.xf = Affine::IDENTITY; + let ring = text_node(&mut doc, ring); + doc.insert(Some(layer), usize::MAX, ring).unwrap(); + let (t, mirrored, holed, returned) = + (text_node(&mut doc, t), text_node(&mut doc, mirrored), text_node(&mut doc, holed), text_node(&mut doc, returned)); + doc.insert(Some(layer), usize::MAX, t).unwrap(); + doc.insert(Some(layer), usize::MAX, mirrored).unwrap(); + doc.insert(Some(layer), usize::MAX, holed).unwrap(); + doc.insert(Some(layer), usize::MAX, returned).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::TYPE]); + assert!(!program(&doc).contains("%AI11_BeginTextDocument")); + let back = read_back(&doc); + let kinds: Vec = back.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, NodeKind::Group { .. })).collect(); + assert_eq!(kinds, [true; 6]); +} + +#[test] +fn type_attributes_the_data_cant_hold_are_reported() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let st = vectorcraft_doc::CharStyle { underline: true, ..style(18.0, Color::BLACK) }; + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Under", st); + let t = text_node(&mut doc, t); + doc.insert(Some(layer), usize::MAX, t).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::TYPE_ATTRIBUTES]); + let back = read_back(&doc); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Text(_))); + // Any other attribute the text document doesn't hold is reported too: No Break, a paragraph's + // left-to-right direction set on it, Optical Margin Alignment, a font version, a font that isn't + // installed whose name doesn't come back (a hyphen in its family), a Lab color, + // small capitals at another size, right-to-left letters aligned to the start of a paragraph + // without a direction of its own, tracking that isn't a whole number. + for edit in [ + (|t: &mut vectorcraft_doc::TextObject| t.runs[0].style.no_break = true) as fn(&mut vectorcraft_doc::TextObject), + |t| t.runs[0].style.tracking = 12.5, + |t| t.para.direction = Some(vectorcraft_doc::ParaDirection::LeftToRight), + |t| t.optical_margin = true, + |t| t.runs[0].style.font_version = Some("2.0".into()), + |t| t.runs[0].style.font_family = "my-missing font".into(), + |t| t.runs[0].style.fill = Paint::solid(MINT), + |t| t.runs[0].style.small_caps = Some(80.0), + |t| { + t.runs[0].text = "\u{645}\u{631}\u{62d}\u{628}\u{627}".into(); + t.para.justify = vectorcraft_doc::Justify::Auto; + }, + ] { + let with = |t: vectorcraft_doc::TextObject| { + let mut d = Document::new(200.0, 100.0); + let layer = d.layers[0].id; + let n = text_node(&mut d, t); + d.insert(Some(layer), usize::MAX, n).unwrap(); + export_ai_editing(&d, &opts()).unwrap().warnings + }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Kept", style(18.0, Color::BLACK)); + assert!(with(t.clone()).is_empty()); + // A font that isn't installed keeps its family and style as its name. + let mut missing = t.clone(); + missing.runs[0].style.font_family = "Missing Sans Probe".into(); + missing.runs[0].style.font_style = "Bold Italic".into(); + assert!(with(missing).is_empty()); + edit(&mut t); + assert_eq!(with(t), [writer::TYPE_ATTRIBUTES]); + } +} + +/// On a large canvas, area type, threaded frames and type on a path stay type: the frames and +/// lengths are written at the canvas's scale. Frames of a thread at two scales, and area type +/// scaled by two factors, are written as outlines. +#[test] +fn scaled_area_type_and_type_on_a_path_stay_type() { + use vectorcraft_doc::{TextKind, TextObject}; + let mut doc = Document::new(20_000.0, 10_000.0); + let layer = doc.layers[0].id; + let framed = |text: &str, r: Rect| { + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, text, style(100.0, Color::BLACK)); + t.kind = TextKind::Area { frame: shapes::rectangle(r) }; + t.xf = Affine::IDENTITY; + t + }; + let mut area = framed("Area", Rect::new(1000.0, 1000.0, 5000.0, 3000.0)); + area.area.columns = 2; + area.area.gutter = 200.0; + let mut on_path = TextObject::point(vectorcraft_geom::Point::ZERO, "Along", style(100.0, Color::BLACK)); + let mut curve = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((1000.0, 5000.0).into()), + kurbo::PathEl::CurveTo((3000.0, 4000.0).into(), (7000.0, 4000.0).into(), (9000.0, 5000.0).into()), + ])); + curve.subpaths.iter_mut().for_each(|sp| sp.closed = false); + on_path.kind = TextKind::OnPath { path: curve, start: 0.0, end: None }; + on_path.xf = Affine::IDENTITY; + let first = text_node(&mut doc, framed("A story in two frames", Rect::new(1000.0, 6000.0, 5000.0, 8000.0))); + let second = text_node(&mut doc, framed("", Rect::new(6000.0, 6000.0, 10_000.0, 8000.0))); + doc.text_threads.push(vec![first.id, second.id]); + let (area, on_path) = (text_node(&mut doc, area), text_node(&mut doc, on_path)); + for n in [area, on_path, first, second] { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + let art = back.layers[0].children().unwrap(); + let texts: Vec<&TextObject> = art.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect(); + assert_eq!(texts.len(), 4); + assert_eq!(back.text_threads.len(), 1); + let TextKind::Area { frame } = &texts[0].kind else { panic!("area type") }; + let b = frame.transformed(texts[0].xf).bounds().unwrap(); + assert_eq!([b.x0, b.y0, b.x1, b.y1].map(r3), ["1000.000", "1000.000", "5000.000", "3000.000"].map(String::from)); + // The reader scales the document back: its matrix holds the canvas's scale, and its lengths + // through that matrix are the ones written. + let k = |t: &TextObject| t.xf.as_coeffs()[0]; + assert_eq!( + (r3(texts[0].runs[0].style.size * k(texts[0])), texts[0].area.columns, r3(texts[0].area.gutter * k(texts[0]))), + ("100.000".to_string(), 2, "200.000".to_string()) + ); + assert!(matches!(texts[1].kind, TextKind::OnPath { .. })); + assert_eq!(r3(texts[1].runs[0].style.size * k(texts[1])), "100.000"); + + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut uneven = framed("Uneven", Rect::new(10.0, 10.0, 50.0, 30.0)); + uneven.xf = Affine::new([2.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + let uneven = text_node(&mut doc, uneven); + doc.insert(Some(layer), usize::MAX, uneven).unwrap(); + assert!(!program(&doc).contains("/AI11Text")); + + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let first = framed("A story in two frames", Rect::new(10.0, 10.0, 50.0, 30.0)); + let mut second = framed("", Rect::new(30.0, 20.0, 50.0, 30.0)); + second.xf = Affine::scale(2.0); + let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second)); + doc.text_threads.push(vec![first.id, second.id]); + doc.insert(Some(layer), usize::MAX, first).unwrap(); + doc.insert(Some(layer), usize::MAX, second).unwrap(); + assert!(!program(&doc).contains("/AI11Text")); +} + +/// See-through type with the knockout shape flag comes back with the flag and writes no warning. +#[test] +fn the_knockout_shape_of_type_comes_back() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut text = text_node(&mut doc, vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 40.0), "Hi", style(10.0, Color::BLACK))); + (text.opacity, text.knockout_shape) = (0.5, true); + doc.insert(Some(layer), usize::MAX, text).unwrap(); + assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.is_empty()); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Text(_))); + assert_eq!((n.opacity, n.knockout_shape), (0.5, true)); +} + +/// An empty frame after a slice that ends with a paragraph break holds the story's last paragraph, +/// and that paragraph comes back with the empty frame's style. +#[test] +fn an_empty_last_frame_keeps_the_style_of_the_last_paragraph() { + use vectorcraft_doc::{Justify, ParaStyle, TextKind, TextObject}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut first = TextObject::point(vectorcraft_geom::Point::ZERO, "Ends\n", style(10.0, Color::BLACK)); + first.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 40.0)) }; + first.xf = Affine::IDENTITY; + let mut second = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK)); + second.set_all_paras(ParaStyle { justify: Justify::Right, ..ParaStyle::default() }); + second.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(110.0, 10.0, 190.0, 40.0)) }; + second.xf = Affine::IDENTITY; + let (first, second) = (text_node(&mut doc, first), text_node(&mut doc, second)); + doc.text_threads.push(vec![first.id, second.id]); + doc.insert(Some(layer), usize::MAX, first).unwrap(); + doc.insert(Some(layer), usize::MAX, second).unwrap(); + let back = read_back(&doc); + let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") }; + assert_eq!(t.plain_text(), "Ends\n"); + let justify: Vec = t.paragraph_styles().iter().map(|p| p.justify).collect(); + assert_eq!(justify, [Justify::Left, Justify::Right]); +} + +/// A minimum first baseline with the Ascent first baseline is reported, and kept with the others. +/// An end bracket of an open path that the reader would read as none is reported, and one further +/// from the path's end comes back. +#[test] +fn a_first_baseline_minimum_and_a_path_end_the_reader_cant_read_are_reported() { + use vectorcraft_doc::{FirstBaseline, TextKind, TextObject}; + let with = |t: TextObject| { + let mut d = Document::new(300.0, 200.0); + let layer = d.layers[0].id; + let n = text_node(&mut d, t); + d.insert(Some(layer), usize::MAX, n).unwrap(); + let warnings = export_ai_editing(&d, &opts()).unwrap().warnings; + (warnings, read_back(&d)) + }; + let mut area = TextObject::point(vectorcraft_geom::Point::ZERO, "Area", style(12.0, Color::BLACK)); + area.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(20.0, 20.0, 140.0, 160.0)) }; + area.xf = Affine::IDENTITY; + area.area.first_baseline_min = 40.0; + let (warnings, _) = with(area.clone()); + assert_eq!(warnings, [writer::TYPE_ATTRIBUTES]); + area.area.first_baseline = FirstBaseline::Fixed; + let (warnings, back) = with(area); + assert!(warnings.is_empty(), "{warnings:?}"); + let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") }; + assert_eq!((t.area.first_baseline, t.area.first_baseline_min), (FirstBaseline::Fixed, 40.0)); + + let on_path = |end: Option| { + let mut curve = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((20.0, 150.0).into()), + kurbo::PathEl::CurveTo((50.0, 110.0).into(), (110.0, 110.0).into(), (140.0, 150.0).into()), + ])); + curve.subpaths.iter_mut().for_each(|sp| sp.closed = false); + let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "On", style(12.0, Color::BLACK)); + t.kind = TextKind::OnPath { path: curve, start: 0.0, end }; + t.xf = Affine::IDENTITY; + t + }; + assert_eq!(with(on_path(Some(0.999))).0, [writer::TYPE_ATTRIBUTES]); + assert!(with(on_path(None)).0.is_empty()); + let (warnings, back) = with(on_path(Some(0.5))); + assert!(warnings.is_empty(), "{warnings:?}"); + let NodeKind::Text(t) = &back.layers[0].children().unwrap()[0].kind else { panic!("type") }; + let TextKind::OnPath { end: Some(end), .. } = t.kind else { panic!("an end bracket") }; + assert!((end - 0.5).abs() < 1e-3, "{end}"); +} + +/// A right-to-left paragraph aligned to the start of its direction comes back aligned right, as +/// it draws; Latin type aligned to the start comes back aligned left. +#[test] +fn type_aligned_to_the_start_of_its_direction_keeps_its_side() { + use vectorcraft_doc::{Justify, ParaDirection, ParaStyle, TextObject}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let mut rtl = TextObject::point(vectorcraft_geom::Point::new(150.0, 40.0), "\u{5e9}\u{5dc}\u{5d5}\u{5dd}", style(12.0, Color::BLACK)); + rtl.set_all_paras(ParaStyle { justify: Justify::Auto, direction: Some(ParaDirection::RightToLeft), ..ParaStyle::default() }); + let mut ltr = TextObject::point(vectorcraft_geom::Point::new(20.0, 80.0), "Start", style(12.0, Color::BLACK)); + ltr.set_all_paras(ParaStyle { justify: Justify::Auto, ..ParaStyle::default() }); + let (rtl, ltr) = (text_node(&mut doc, rtl), text_node(&mut doc, ltr)); + doc.insert(Some(layer), usize::MAX, rtl).unwrap(); + doc.insert(Some(layer), usize::MAX, ltr).unwrap(); + assert!(export_ai_editing(&doc, &opts()).unwrap().warnings.is_empty()); + let back = read_back(&doc); + let sides: Vec = + back.layers[0].children().unwrap().iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.para.justify) } else { None }).collect(); + assert_eq!(sides, [Justify::Right, Justify::Left]); +} + +/// Type in an opacity mask is written as its outlines; the type beside it as text. +#[test] +fn type_in_opacity_masks_is_written_as_its_outlines() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let label = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 40.0), "Mask", style(18.0, Color::BLACK)); + let label = text_node(&mut doc, label); + let mut square = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(1.0, 0.0, 0.0)); + square.extras_mut().mask = Some(Box::new(OpacityMask::new(label, false))); + doc.insert(Some(layer), usize::MAX, square).unwrap(); + let plain = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Plain", style(18.0, Color::BLACK)); + let plain = text_node(&mut doc, plain); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::TYPE]); + let text = program(&doc); + assert_eq!(text.matches("/AI11Text :").count(), 1); +} + +/// Type with an opacity mask of its own is written as its outlines, which keep the mask. +#[test] +fn type_with_an_opacity_mask_is_written_as_its_outlines() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 70.0), "Masked", style(18.0, Color::BLACK)); + let mut t = text_node(&mut doc, t); + let art = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(1.0, 1.0, 1.0)); + t.extras_mut().mask = Some(Box::new(OpacityMask::new(art, false))); + doc.insert(Some(layer), usize::MAX, t).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::TYPE]); + assert_eq!(program(&doc).matches("/AI11Text :").count(), 0); + let back = read_back(&doc); + let top = &back.layers[0].children().unwrap()[0]; + assert!(!matches!(top.kind, NodeKind::Text(_))); + assert!(top.extras().mask.is_some()); +} diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 78602b939..280fbb17b 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use vectorcraft_color::cms::Cms; use vectorcraft_color::swatch::{REGISTRATION, Swatch}; use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeKind, OpacityMask, StrokeLayer}; +use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath}; use vectorcraft_pathops::BoolOp; @@ -17,19 +17,22 @@ use crate::ps; mod images; mod paints; +mod texts; pub(super) use images::PAYLOAD; #[cfg(test)] pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; #[cfg(test)] pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; +#[cfg(test)] +pub(super) use texts::TYPE_ATTRIBUTES; /// How deep symbols, live objects and pattern tiles may nest in one another (deeper art is left /// out). Groups and layers need no limit of their own: a document is never deeper than /// [`vectorcraft_doc::MAX_DEPTH`]. const MAX_NEST: u32 = 8; -pub(super) const TYPE: &str = "type is written as its outlines in the editing data for other apps"; +pub(super) const TYPE: &str = "type in opacity masks, pattern tiles and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type that flows around a text wrap, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps"; pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps"; pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps"; @@ -157,6 +160,8 @@ pub(super) struct Writer<'a> { /// How many parts of the data written on `%_` lines (opacity masks' art, patterns' tiles) the /// art being written is in. hidden: u32, + /// The text document: the stories of the type written as text. + pub(super) texts: texts::TextDoc, } impl<'a> Writer<'a> { @@ -176,6 +181,7 @@ impl<'a> Writer<'a> { canvas_origin: origin, payloads: vec![], hidden: 0, + texts: texts::TextDoc::default(), cms: vectorcraft_color::cms::active(), body: String::new(), warnings: vec![], @@ -880,12 +886,39 @@ impl<'a> Writer<'a> { // ---------- type ---------- + /// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the + /// text document can hold it, outside opacity masks, pattern tiles and art cut to a clipping + /// object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type names the + /// story of its thread, which its first frame holds. + fn text(&mut self, n: &Node, xy: Xy) { + let NodeKind::Text(t) = &n.kind else { return }; + let doc = self.doc; + let placed = if self.hidden > 0 || self.clip.is_some() { + None + } else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) { + // A thread is checked for what it loses, in every frame, when its story is written. + let placed = self.texts.thread(doc, thread, n.id, self.canvas, &|id| !cut_to_clipping_object(doc, id)); + placed.map(|p| (p, self.texts.take_lost())) + } else if texts::holds(n, t) { + self.texts.add(t, self.canvas).map(|story| ((story, 0), texts::loses(t))) + } else { + None + }; + let Some(((story, frame), lost)) = placed else { return self.outlined_text(n, xy) }; + if lost { + self.warn(texts::TYPE_ATTRIBUTES); + } + // The stub takes the transparency set before it, as a path does. + self.set_xy(xy.object()); + texts::stub(&mut self.body, story, frame, t, self.to_art); + } + /// Type `n` as a group of what Type › Create Outlines makes of it /// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and /// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline /// graphics as their symbols' art where the layout places them, as the PDF page draws them; /// the group has the type's transparency `xy`. - fn text(&mut self, n: &Node, xy: Xy) { + fn outlined_text(&mut self, n: &Node, xy: Xy) { self.warn(TYPE); self.line("u"); let doc = self.doc; @@ -1153,6 +1186,18 @@ fn compound_path(n: &Node) -> PathData { /// The outline and fill rule a clipping path or compound path `n` clips by; `None` for other /// clipping objects and for a path without points (it clips everything away, see /// [`Writer::clip_contents`]). +/// Is object `id` of `doc` in (or inside) a clipping group or clipping layer whose clipping object +/// isn't a path or compound path (its art is written cut to that object, [`Writer::clip_group`])? +/// Type that is a clipping object is one of these: it isn't a path. +fn cut_to_clipping_object(doc: &Document, id: NodeId) -> bool { + let ancestry = doc.ancestry(id).unwrap_or_default(); + let above = ancestry.split_last().map(|(_, a)| a).unwrap_or_default(); + above.iter().filter_map(|a| doc.node(*a)).any(|p| { + matches!(p.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. }) + && p.children().and_then(|c| c.first()).is_none_or(|c| clip_path(c).is_none()) + }) +} + fn clip_path(n: &Node) -> Option<(PathData, FillRule)> { let (path, rule) = match &n.kind { NodeKind::Path { path, rule, guide: false, .. } => (path.clone(), *rule), diff --git a/crates/eps/src/ai_editing/writer/texts.rs b/crates/eps/src/ai_editing/writer/texts.rs new file mode 100644 index 000000000..6a289d5d7 --- /dev/null +++ b/crates/eps/src/ai_editing/writer/texts.rs @@ -0,0 +1,1013 @@ +//! Type as the text document of the editing data (`%AI11_BeginTextDocument`): the characters of each +//! text object, their character and paragraph attributes and where they sit, as the text document +//! reader reads them ([`crate::import`]'s `ate.rs`), and a text object stub (`/AI11Text`) in the +//! layers naming its story. +//! +//! The document is ASCII85 on comment lines of nested dictionaries with numbers for keys. Its root +//! holds the version (`/98`), the resources (`/0`: fonts, character and paragraph style sheets, the +//! frames, and empty sets of the resources the writer has none of) and the objects (`/1`: the +//! document's text settings, the stories, and the attributes stories start from). A story holds its +//! characters (paragraphs end in `\r`, a forced line break is `\x03`), its paragraph and character +//! runs (each a length in characters and its attributes) and the frames it flows through: point type +//! (a point on the canvas and, when it is turned, a matrix), area type (the frame's cubic segments, +//! rows, columns, gutters, first baseline) or type on a path (the path's cubic segments and where +//! the type starts and ends on it). Coordinates are the canvas's (y down, 16383 points a side, its +//! center the center of `%AI3_TemplateBox`). The stories carry no composed lines: the app that +//! opens the file lays them out. + +use std::collections::{HashMap, HashSet}; +use std::fmt::Write; + +use kurbo::{ParamCurveArclen, PathSeg}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{ + AreaOptions, Burasagari, CharStyle, Document, FirstBaseline, Justify, Mojikumi, Node, NodeId, NodeKind, ParaDirection, ParaStyle, PathAlign, + PathEffect, TextKind, TextObject, VerticalAlign, +}; +use vectorcraft_geom::{Affine, PathData, Point}; + +use crate::ps; + +/// What the text document holds of type, and what it leaves out. +pub(in crate::ai_editing) const TYPE_ATTRIBUTES: &str = "attributes of type other than its font, size, leading, tracking, baseline shift, horizontal and vertical scale, solid fill, solid stroke and stroke weight, capitals, small capitals at the document's size, Tate-chu-yoko, paragraph alignment, indents, space before and after, hyphenation on or off and right-to-left direction (for example underline, kerning, OpenType features, swatch links of its colors, character and paragraph styles, tabs, lists, optical margin alignment, area type's inset spacing, vertical alignment and flow, a minimum first baseline with the Ascent first baseline, and type on a path's effect, alignment and spacing and an end bracket at the very end of an open path) are left out of the editing data for other apps"; + +/// The version of the text document's form (`/98 << /0 n >>`), as the newest files users sent have it. +const STREAM_VERSION: u32 = 14; +/// The font that shows hidden characters, as the files users sent name it, last in the font set. +const HIDDEN_GLYPH_FONT: &str = "AdobeInvisFont"; +/// The hidden characters that font shows: each whitespace character and the glyph it shows as. +const HIDDEN_GLYPHS: [(&str, &str); 7] = + [(" ", "1"), ("\r", "6"), ("\t", "0"), ("\u{2029}", "5"), ("\u{3}", "5"), ("\u{3000}", "1"), ("\u{ad}", "3")]; +/// Most characters a line of ASCII85 holds. +const LINE: usize = 100; + +/// A value of the text document. +enum V { + Int(i64), + Real(f64), + /// A real to ten decimals (the turn and scale of a matrix, which the reader divides by). + Fine(f64), + /// A real to eight decimals (the type's lengths and factors, which may be small; a size the reader + /// multiplies by a matrix's scale comes back within these). + Small(f64), + Bool(bool), + Str(String), + Name(&'static str), + List(Vec), + Dict(Vec<(u32, V)>), +} + +impl V { + /// How many values the reader counts in `self` (each value, list and dictionary one). + fn count(&self) -> usize { + match self { + V::List(l) => 1 + l.iter().map(V::count).sum::(), + V::Dict(d) => 1 + d.iter().map(|(_, v)| v.count()).sum::(), + _ => 1, + } + } + + fn write(&self, out: &mut Vec) { + match self { + V::Int(n) => out.extend_from_slice(n.to_string().as_bytes()), + V::Real(r) => { + let mut s = String::new(); + ps::push_num(&mut s, if r.is_finite() { *r } else { 0.0 }); + let s = s.trim_end(); + out.extend_from_slice(s.as_bytes()); + // A real is written with a point, as the files users sent write them. + if !s.contains(['.', 'e', 'E']) { + out.extend_from_slice(b".0"); + } + } + V::Fine(r) => decimals(*r, 10, out), + V::Small(r) => decimals(*r, 8, out), + V::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }), + V::Str(s) => { + out.push(b'('); + let mut bytes = vec![0xfe, 0xff]; + for u in s.encode_utf16() { + bytes.extend_from_slice(&u.to_be_bytes()); + } + for b in bytes { + if matches!(b, b'(' | b')' | b'\\') { + out.push(b'\\'); + } + out.push(b); + } + out.push(b')'); + } + V::Name(n) => { + out.push(b'/'); + out.extend_from_slice(n.as_bytes()); + } + V::List(l) => { + out.push(b'['); + for v in l { + out.push(b' '); + v.write(out); + } + out.extend_from_slice(b" ]"); + } + V::Dict(d) => { + out.extend_from_slice(b"<<"); + for (k, v) in d { + let _ = write!(Bytes(out), " /{k} "); + v.write(out); + } + out.extend_from_slice(b" >>"); + } + } + } +} + +/// `r` to `n` decimals (trailing zeros left out, a point always), into `out`. +fn decimals(r: f64, n: i32, out: &mut Vec) { + let k = 10f64.powi(n); + let r = if r.is_finite() && (r * k).is_finite() { (r * k).round() / k } else { 0.0 }; + let s = format!("{:.*}", n as usize, r + 0.0); + let s = s.trim_end_matches('0'); + out.extend_from_slice(s.as_bytes()); + if s.ends_with('.') { + out.push(b'0'); + } +} + +/// `fmt::Write` into bytes. +struct Bytes<'a>(&'a mut Vec); + +impl std::fmt::Write for Bytes<'_> { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + self.0.extend_from_slice(s.as_bytes()); + Ok(()) + } +} + +/// A length or a factor of the type's attributes, to eight decimals. +fn real(v: f64) -> V { + V::Small(v) +} + +fn reals(v: &[f64]) -> V { + V::List(v.iter().map(|x| V::Real(*x)).collect()) +} + +fn fines(v: &[f64]) -> V { + V::List(v.iter().map(|x| V::Fine(*x)).collect()) +} + +fn dict(pairs: Vec<(u32, V)>) -> V { + V::Dict(pairs) +} + +/// A resource set: its resources, each `<< /0 resource >>`, and its display list. +fn set(resources: Vec, display: bool) -> V { + let mut d = vec![(0, V::List(resources.into_iter().map(|r| dict(vec![(0, r)])).collect()))]; + if display { + d.push((1, V::List(vec![dict(vec![(0, V::Int(0))])]))); + } + dict(d) +} + +/// A paint: kind 0 gray (`[alpha, lightness]`), 1 RGB, 2 CMYK, as the reader's `paint` reads them. +fn paint(c: &Color) -> V { + let (kind, values) = match *c { + Color::Gray { k } => (0, vec![1.0 - f64::from(k.clamp(0.0, 1.0))]), + Color::Cmyk { c, m, y, k } => (2, [c, m, y, k].iter().map(|v| f64::from(v.clamp(0.0, 1.0))).collect()), + Color::Rgb { .. } | Color::Lab { .. } => (1, c.to_rgb().iter().map(|v| f64::from(v.clamp(0.0, 1.0))).collect()), + }; + let values: Vec = std::iter::once(1.0).chain(values).collect(); + dict(vec![(99, V::Name("SimplePaint")), (0, dict(vec![(0, V::Int(kind)), (1, reals(&values))]))]) +} + +/// The color of paint `p` when the text document can hold it (a solid color or none). +fn solid(p: &Paint) -> Option> { + match p { + Paint::Solid { color, .. } => Some(Some(*color)), + Paint::None => Some(None), + Paint::Gradient(_) | Paint::Pattern { .. } => None, + } +} + +/// Can the text document hold type `t` of object `n` as it is: point type that isn't mirrored, or +/// area type or type on a path that is only moved ([`shape`]); characters other than inline graphics +/// and ruby; solid fills and strokes; no fills, strokes, effects or opacity mask of the object's +/// own; some text. +pub(in crate::ai_editing) fn holds(n: &Node, t: &TextObject) -> bool { + let xf = t.xf.as_coeffs(); + let shape_ok = match &t.kind { + TextKind::Point => xf.iter().all(|v| v.is_finite()) && t.xf.determinant() > 1e-12, + TextKind::Area { .. } => shape(t).as_ref().and_then(segments).is_some_and(|s| closed(&s)), + // An open path whose ends meet would be read back closed. + TextKind::OnPath { path, .. } => { + let open = path.subpaths.iter().any(|sp| !sp.anchors.is_empty() && !sp.closed); + shape(t).as_ref().and_then(segments).is_some_and(|s| !(open && closed(&s))) + } + }; + // Area type that flows around wrap objects: the data doesn't hold their wrap settings. + shape_ok + && t.wrap.is_empty() + && t.ruby.is_empty() + && t.runs.iter().any(|r| r.text.chars().any(|c| !c.is_whitespace())) + && runs_held(t) + && plain(n) + && within_reader(t, story_scale(t)) +} + +/// Document points to a unit of the story of type `t`: its scale is put into its lengths. +fn story_scale(t: &TextObject) -> f64 { + 1.0 / t.xf.determinant().abs().sqrt() +} + +/// Are the lengths and scales of type `t`, written at `scale`, within what the reader's +/// `Texts::style`, `Texts::para` and `AreaFrame::read` read (it reads others as their defaults)? +fn within_reader(t: &TextObject, scale: f64) -> bool { + const MAX_LENGTH: f64 = 1e5; + let length = |v: f64| (v / scale).is_finite() && (v / scale).abs() < MAX_LENGTH; + // Scales are written as fractions to eight decimals. + let factor = |v: f64| v.is_finite() && v / 100.0 >= 1e-7 && v / 100.0 < 100.0; + let char_ok = |s: &CharStyle| { + // Sizes are written to eight decimals. + length(s.size) + && s.size / scale >= 1e-7 + && s.leading.is_none_or(|l| length(l) && l / scale >= 1e-7) + // Tracking is written rounded to a whole number. + && s.tracking.is_finite() + && s.tracking.round().abs() < MAX_LENGTH + && length(s.baseline_shift) + && factor(s.h_scale) + && factor(s.v_scale) + && (s.stroke.is_none() || (s.stroke_width.is_finite() && s.stroke_width >= 0.0 && s.stroke_width / scale < 1e4)) + }; + let para_ok = |p: &ParaStyle| [p.left_indent, p.right_indent, p.first_line_indent, p.space_before, p.space_after].into_iter().all(length); + let max = vectorcraft_doc::AreaOptions::MAX_ROWS_COLUMNS; + let area_ok = |o: &AreaOptions| { + [o.gutter, o.row_gutter.unwrap_or(o.gutter), o.first_baseline_min].iter().all(|g| g.is_finite() && *g >= 0.0 && *g / scale <= 10_000.0) + && o.rows <= max + && o.columns <= max + }; + t.runs.iter().all(|r| char_ok(&r.style)) + && t.paragraph_styles().iter().all(para_ok) + && (!matches!(t.kind, TextKind::Area { .. }) || area_ok(&t.area)) +} + +/// Are the runs of `t` characters (no inline graphics, and none of the characters a story ends its +/// paragraphs and lines with, `\r` and `\x03`) filled and stroked with solid colors or nothing? +fn runs_held(t: &TextObject) -> bool { + t.runs + .iter() + .all(|r| r.inline.is_none() && !r.text.contains(['\r', '\u{3}']) && solid(&r.style.fill).is_some() && solid(&r.style.stroke).is_some()) +} + +/// The story of a thread whose frames `texts` (in order) each hold a slice of it: their runs one +/// after another, and their paragraphs' attributes, a paragraph split between two frames once (a +/// frame whose text before it doesn't end with a paragraph break continues that paragraph), and +/// whether a paragraph split between two frames has other attributes in the second (left out: +/// the story keeps the first's). +fn joined(texts: &[&TextObject]) -> Option<(TextObject, bool)> { + let mut whole = (*texts.first()?).clone(); + whole.runs.clear(); + let mut last: Option = None; + let mut differs = false; + // Whether the story so far has text, and ends with a paragraph break. + let (mut started, mut ended) = (false, false); + for t in texts.iter().filter(|t| t.runs.iter().any(|r| !r.text.is_empty())) { + let continues = started && !ended; + (started, ended) = (true, t.plain_text().ends_with('\n')); + let own = t.paragraph_styles(); + if continues && own.first() != last.as_ref() { + differs = true; + } + last = own.last().cloned(); + whole.runs.extend(t.runs.iter().filter(|r| !r.text.is_empty()).cloned()); + } + if whole.runs.is_empty() { + whole.runs = texts.first()?.runs.clone(); + } + whole.set_paragraph_styles(vectorcraft_text::thread::story_paras(texts, &[])); + Some((whole, differs)) +} + +/// The frame of area type or the path of type on a path, in document coordinates: when the matrix +/// of type `t` only moves it and scales it by one factor (as a large canvas does), its frame or +/// path through that matrix ([`story_scale`] puts the factor into its lengths). `None` for point +/// type and for type that is turned, mirrored, skewed or scaled by two factors. +fn shape(t: &TextObject) -> Option { + let [a, b, c, d, e, f] = t.xf.as_coeffs(); + let even = a > 0.0 && (a - d).abs() <= a * 1e-9 && b == 0.0 && c == 0.0 && e.is_finite() && f.is_finite(); + match &t.kind { + TextKind::Area { frame } if even => Some(frame.transformed(t.xf)), + TextKind::OnPath { path, .. } if even => Some(path.transformed(t.xf)), + _ => None, + } +} + +/// Has text object `n` no fills, strokes or effects of its own that paint or show, and no opacity +/// mask (the reader doesn't keep a text object's mask)? +fn plain(n: &Node) -> bool { + n.appearance.items.iter().all(|i| !i.visible() || (i.paint().is_none() && !i.effects().iter().any(|e| e.visible))) + && !n.appearance.effects.iter().any(|e| e.visible) + && n.extras().mask.is_none() +} + +/// Is the end bracket of type on an open path so near the path's end that the reader reads it as +/// no bracket (`Story::node`: within 0.02 of a segment)? +fn end_read_as_none(t: &TextObject) -> bool { + let (Some((_, end)), Some(segs)) = (t.kind.path_span(), shape(t).as_ref().and_then(segments)) else { return false }; + !closed(&segs) && end < 1.0 - 1e-9 && place_on(&segs, end) >= segs.len() as f64 - 0.02 +} + +/// Has type `t` attributes the text document leaves out ([`TYPE_ATTRIBUTES`])? +pub(in crate::ai_editing) fn loses(t: &TextObject) -> bool { + // Small capitals are read back at Document Setup's default size. + let default_small_caps = vectorcraft_doc::DocSetup::default().small_caps_size; + // A run's style as the text document holds it: every other attribute at its default. A color + // is held without its swatch link. + let held_char = |s: &CharStyle| { + let unlinked = |p: &Paint| match p { + Paint::Solid { color, .. } => Paint::solid(*color), + other => other.clone(), + }; + let stroked = !s.stroke.is_none(); + CharStyle { + font_family: s.font_family.clone(), + font_style: s.font_style.clone(), + size: s.size, + leading: s.leading, + tracking: s.tracking.round(), + baseline_shift: s.baseline_shift, + h_scale: s.h_scale, + v_scale: s.v_scale, + fill: unlinked(&s.fill), + stroke: unlinked(&s.stroke), + stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, + all_caps: s.all_caps, + small_caps: s.small_caps.filter(|v| *v == default_small_caps), + tate_chu_yoko: s.tate_chu_yoko, + ..CharStyle::default() + } + }; + let run_lost = |s: &CharStyle| { + let stroked = !s.stroke.is_none(); + let s = CharStyle { stroke_width: if stroked { s.stroke_width } else { CharStyle::default().stroke_width }, ..s.clone() }; + // A Lab color is written as RGB. + let lab = |p: &Paint| matches!(p, Paint::Solid { color: Color::Lab { .. }, .. }); + s != held_char(&s) || lab(&s.fill) || lab(&s.stroke) || !font_kept(&s) + }; + // A paragraph's style as the text document holds it, and its text: aligned to the start of + // its direction (Auto) without a direction of its own, it comes back aligned left, which + // differs when its first strong letter is right-to-left. + let para_lost = |(p, text): (&ParaStyle, &str)| { + let rtl_letter = text.chars().find_map(strong_direction) == Some(true); + if p.justify == Justify::Auto && p.direction.is_none() && rtl_letter { + return true; + } + let held = ParaStyle { + justify: p.justify, + left_indent: p.left_indent, + right_indent: p.right_indent, + first_line_indent: p.first_line_indent, + space_before: p.space_before, + space_after: p.space_after, + hyphenate: p.hyphenate, + direction: p.direction.filter(|d| *d == ParaDirection::RightToLeft), + // The composition of Japanese punctuation new type gets (Line-end Punctuation Half + // Width, Standard burasagari) isn't reported: it changes no other script's lines. + mojikumi: if p.mojikumi == Mojikumi::LineEndHalf { p.mojikumi } else { ParaStyle::default().mojikumi }, + burasagari: if p.burasagari == Burasagari::Standard { p.burasagari } else { ParaStyle::default().burasagari }, + ..ParaStyle::default() + }; + *p != held + }; + let area = AreaOptions::default(); + let text = t.plain_text(); + t.runs.iter().any(|r| run_lost(&r.style)) + || t.paragraph_styles().iter().zip(text.split('\n')).any(para_lost) + || t.optical_margin + || t.system_layout + || !t.fractional_widths + || (matches!(t.kind, TextKind::Area { .. }) + && (t.area.inset != area.inset + || t.area.vertical_align != VerticalAlign::default() + || t.area.flow != area.flow + || t.area.fit != area.fit + // The text document holds the Ascent first baseline by leaving it out, without a minimum. + || (t.area.first_baseline == FirstBaseline::Ascent && t.area.first_baseline_min != 0.0))) + || (matches!(t.kind, TextKind::OnPath { .. }) + && (t.path_effect != PathEffect::default() || t.path_align != PathAlign::default() || t.path_spacing != 0.0 || end_read_as_none(t))) +} + +/// The name the font of style `st` is written under, and whether it's TrueType: an installed +/// face's PostScript name; else its family and style as `family-style` (the family alone for +/// Regular), the form the reader's `family_style` splits. +fn font_name(st: &CharStyle) -> (String, bool) { + let db = vectorcraft_text::FontDb::global(); + // The face of that family and style (the font database gives a fallback face for others). + match db + .face_version(&st.font_family, &st.font_style, st.font_version.as_deref()) + .filter(|f| f.family == st.font_family && f.style == st.font_style) + { + Some(face) => (face.postscript_name(), face.is_truetype()), + None if st.font_style.is_empty() || st.font_style == "Regular" => (st.font_family.clone(), false), + None => (format!("{}-{}", st.font_family, st.font_style.replace(' ', "")), false), + } +} + +/// Does the font of style `st` come back as itself: the family and style the reader makes of the +/// name it is written under (as its `char_style` does)? +fn font_kept(st: &CharStyle) -> bool { + let (family, style) = crate::family_style(&font_name(st).0); + let family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family); + family == st.font_family && style == st.font_style +} + +/// Whether `c` is a strong right-to-left letter (`Some(true)`: Hebrew, Arabic, Syriac, Thaana, NKo +/// and their presentation forms) or a strong left-to-right one (`Some(false)`: any other letter). +fn strong_direction(c: char) -> Option { + let rtl = matches!(c, '\u{0590}'..='\u{08FF}' | '\u{FB1D}'..='\u{FDFF}' | '\u{FE70}'..='\u{FEFF}'); + if rtl { + Some(true) + } else if c.is_alphabetic() { + Some(false) + } else { + None + } +} + +/// The cubic segments of `path`, eight numbers each (start, two handles, end) in document +/// coordinates; `None` unless it has one subpath with points and no other (a frame of the text +/// document is one path), of no more segments than the reader reads. +fn segments(path: &PathData) -> Option> { + let mut drawn = path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()); + let sp = drawn.next().filter(|sp| sp.anchors.len() > 1)?; + if drawn.next().is_some() { + return None; + } + let bp = PathData::new(vec![sp.clone()]).to_bezpath(); + let out: Vec<[f64; 8]> = bp + .segments() + .map(|s| { + let c = match s { + PathSeg::Line(l) => kurbo::CubicBez::new(l.p0, l.p0, l.p1, l.p1), + PathSeg::Quad(q) => q.raise(), + PathSeg::Cubic(c) => c, + }; + [c.p0.x, c.p0.y, c.p1.x, c.p1.y, c.p2.x, c.p2.y, c.p3.x, c.p3.y] + }) + .collect(); + (!out.is_empty() && out.len() <= crate::import::MAX_SEGMENTS && out.iter().flatten().all(|v| v.is_finite())).then_some(out) +} + +/// Do `segments` end where they start? +fn closed(segments: &[[f64; 8]]) -> bool { + matches!((segments.first(), segments.last()), (Some(f), Some(l)) if (f[0] - l[6]).abs() < 1e-3 && (f[1] - l[7]).abs() < 1e-3) +} + +/// Where the fraction `f` of the length of `segments` lies, as the reader's `length_fraction` +/// reads it back: a segment's index plus the place in that segment. +fn place_on(segments: &[[f64; 8]], f: f64) -> f64 { + const ACCURACY: f64 = 1e-4; + let cubic = |s: &[f64; 8]| kurbo::CubicBez::new((s[0], s[1]), (s[2], s[3]), (s[4], s[5]), (s[6], s[7])); + let lengths: Vec = segments.iter().map(|s| cubic(s).arclen(ACCURACY)).collect(); + let total: f64 = lengths.iter().sum(); + if !(total.is_finite() && total > 0.0) { + return 0.0; + } + let mut left = f.clamp(0.0, 1.0) * total; + for (i, (s, len)) in segments.iter().zip(&lengths).enumerate() { + if left <= *len || i + 1 == segments.len() { + let t = if *len > 0.0 { cubic(s).inv_arclen(left.min(*len), ACCURACY) } else { 0.0 }; + return i as f64 + t.clamp(0.0, 1.0); + } + left -= len; + } + segments.len() as f64 +} + +/// The text document being written. +#[derive(Default)] +pub(in crate::ai_editing) struct TextDoc { + /// Each font's PostScript name and whether it's TrueType. + fonts: Vec<(String, bool)>, + frames: Vec, + stories: Vec, + /// The story and frame each text object of a thread is written as. + threaded: HashMap, + /// The threads (by their first frame) already planned. + planned: HashSet, + /// A thread written since the last [`Self::take_lost`] has attributes the text document leaves + /// out ([`loses`], in any of its frames). + lost: bool, + /// The bytes of the stories written, each counted once for each text object that names it. + text_bytes: usize, + /// The bytes of the frames and stories as written in the text document, and the values the + /// reader counts in them. + doc_bytes: usize, + doc_values: usize, +} + +impl TextDoc { + pub(in crate::ai_editing) fn is_empty(&self) -> bool { + self.stories.is_empty() + } + + /// The index of the font of `st` in the font set. + fn font(&mut self, st: &CharStyle) -> i64 { + let (name, truetype) = font_name(st); + let i = match self.fonts.iter().position(|(n, _)| *n == name) { + Some(i) => i, + None => { + self.fonts.push((name, truetype)); + self.fonts.len() - 1 + } + }; + i as i64 + } + + /// The character attributes of a run in style `st`, at `scale` (document points to a unit of + /// the text document), as the reader's `Texts::style` reads them. + fn char_features(&mut self, st: &CharStyle, scale: f64) -> V { + let font = self.font(st); + let num = |v: f64, d: f64| if v.is_finite() { v } else { d }; + // Sizes to eight decimals: the reader reads a size of 0 as none. + let mut d = vec![(0, V::Int(font)), (1, V::Small(num(st.size, 12.0) / scale))]; + match st.leading { + Some(l) if l.is_finite() && l > 0.0 => d.extend([(4, V::Bool(false)), (5, V::Small(l / scale))]), + _ => d.push((4, V::Bool(true))), + } + d.extend([ + (6, V::Small(num(st.h_scale, 100.0) / 100.0)), + (7, V::Small(num(st.v_scale, 100.0) / 100.0)), + // Tracking is a whole number of thousandths of an em. + (8, V::Int(num(st.tracking, 0.0).round() as i64)), + (9, real(num(st.baseline_shift, 0.0) / scale)), + ]); + let caps = if st.all_caps { + 2 + } else if st.small_caps.is_some() { + 1 + } else { + 0 + }; + d.push((12, V::Int(caps))); + if st.tate_chu_yoko { + d.push((35, V::Int(3))); + } + let fill = solid(&st.fill).flatten(); + // A stroke of no width keeps its paint. + let stroke = solid(&st.stroke).flatten(); + if let Some(c) = &fill { + d.push((53, paint(c))); + } + if let Some(c) = &stroke { + d.push((54, paint(c))); + } + d.extend([(56, V::Bool(fill.is_some())), (57, V::Bool(stroke.is_some()))]); + if stroke.is_some() { + d.push((63, V::Small(st.stroke_width / scale))); + } + dict(d) + } + + /// The paragraph attributes of `p` at `scale`, as the reader's `Texts::para` reads them. + fn para_features(p: &ParaStyle, scale: f64) -> V { + let rtl = p.direction == Some(ParaDirection::RightToLeft); + // A right-to-left paragraph's left and right alignments are each other's. + let justify = match p.justify { + // Auto: the start of the paragraph's direction. + Justify::Auto => 0, + Justify::Left if rtl => 1, + Justify::Right if rtl => 0, + Justify::Left => 0, + Justify::Right => 1, + Justify::Center => 2, + Justify::JustifyLeft => 3, + Justify::JustifyRight => 4, + Justify::JustifyCenter => 5, + Justify::JustifyAll => 6, + }; + let len = |v: f64| real(if v.is_finite() { v / scale } else { 0.0 }); + let mut d = vec![ + (0, V::Int(justify)), + (1, len(p.first_line_indent)), + (2, len(p.left_indent)), + (3, len(p.right_indent)), + (4, len(p.space_before)), + (5, len(p.space_after)), + (9, V::Bool(p.hyphenate)), + ]; + if rtl { + d.push((33, V::Int(1))); + } + dict(d) + } + + /// The story of type `t`: its characters, runs and the frames `frames` (indices into the + /// frame set). `scale`: document points to a unit of the text document. + fn story(&mut self, t: &TextObject, frames: &[usize], scale: f64) -> usize { + let mut text = String::new(); + let mut char_runs = vec![]; + for r in &t.runs { + let piece: String = r + .text + .chars() + .map(|c| match c { + '\n' => '\r', + '\u{2028}' => '\u{3}', + c => c, + }) + .collect(); + let n = piece.chars().count(); + if n == 0 { + continue; + } + text.push_str(&piece); + let features = self.char_features(&r.style, scale); + char_runs.push((features, n)); + } + // Every paragraph ends in `\r`, the last one too; the last run takes it. + text.push('\r'); + match char_runs.last_mut() { + Some(last) => last.1 += 1, + None => { + let features = self.char_features(&CharStyle::default(), scale); + char_runs.push((features, 1)); + } + } + let paras: Vec<(V, usize)> = + text.split_inclusive('\r').enumerate().map(|(i, p)| (Self::para_features(t.para_at(i), scale), p.chars().count())).collect(); + let run = |features: V, n: usize, para: bool| { + let sheet = if para { + dict(vec![(0, V::Str(String::new())), (5, features), (6, V::Int(0))]) + } else { + dict(vec![(0, V::Str(String::new())), (5, V::Int(0)), (6, features)]) + }; + dict(vec![(0, dict(vec![(0, sheet)])), (1, V::Int(n as i64))]) + }; + let para_runs: Vec = paras.into_iter().map(|(f, n)| run(f, n, true)).collect(); + let char_runs: Vec = char_runs.into_iter().map(|(f, n)| run(f, n, false)).collect(); + let model = dict(vec![ + (0, V::Str(text)), + (5, dict(vec![(0, V::List(para_runs))])), + (6, dict(vec![(0, V::List(char_runs))])), + (10, dict(vec![(0, V::Int(4)), (2, V::Bool(true))])), + ]); + let view = dict(vec![(0, V::List(frames.iter().map(|f| dict(vec![(0, V::Int(*f as i64))])).collect()))]); + self.stories.push(dict(vec![(0, model), (1, view)])); + self.stories.len() - 1 + } + + /// The frame of type `t`, `canvas` taking the document onto the canvas (y down) and `scale` + /// being document points to a unit there. Point type's frame is its point and, unless it is + /// neither turned nor scaled, the matrix of its type round that point (scale taken out). + fn frame(&mut self, t: &TextObject, canvas: Affine, scale: f64) -> usize { + let mut data = vec![]; + let mut f = vec![]; + let vertical = t.vertical.then_some((1, V::Int(2))); + let segs = |path: &PathData| -> Vec { + segments(path) + .unwrap_or_default() + .iter() + .flat_map(|s| { + let p = |x: f64, y: f64| canvas * Point::new(x, y); + let (a, b, c, d) = (p(s[0], s[1]), p(s[2], s[3]), p(s[4], s[5]), p(s[6], s[7])); + [a.x, a.y, b.x, b.y, c.x, c.y, d.x, d.y] + }) + .collect() + }; + match &t.kind { + TextKind::Point => { + let at = canvas * Point::new(t.xf.as_coeffs()[4], t.xf.as_coeffs()[5]); + f.push((0, reals(&[at.x, at.y]))); + data.extend(vertical); + let [a, b, c, d, ..] = t.xf.as_coeffs(); + let s = (a * d - b * c).abs().sqrt(); + let (a, b, c, d) = (a / s, b / s, c / s, d / s); + if [a - 1.0, b, c, d - 1.0].iter().any(|v| v.abs() > 1e-9) { + // Round the point: it stays where it is. + let (e, g) = (at.x - (a * at.x + c * at.y), at.y - (b * at.x + d * at.y)); + let mut m = fines(&[a, b, c, d]); + if let V::List(l) = &mut m { + l.extend([V::Real(e), V::Real(g)]); + } + data.push((2, m)); + } + } + TextKind::Area { .. } => { + f.push((1, dict(vec![(0, reals(&shape(t).map(|p| segs(&p)).unwrap_or_default()))]))); + data.push((0, V::Int(1))); + data.extend(vertical); + let o = &t.area; + data.extend([(3, V::Int(o.rows.max(1) as i64)), (4, V::Int(o.columns.max(1) as i64))]); + data.extend([(7, real(o.row_gutter.unwrap_or(o.gutter) / scale)), (8, real(o.gutter / scale))]); + let kind = match o.first_baseline { + FirstBaseline::Ascent => None, + FirstBaseline::Fixed => Some(0), + FirstBaseline::CapHeight => Some(2), + FirstBaseline::Leading => Some(3), + FirstBaseline::XHeight => Some(4), + }; + if let Some(k) = kind { + data.push((10, dict(vec![(0, V::Int(k)), (1, real(o.first_baseline_min / scale))]))); + } + } + TextKind::OnPath { .. } => { + let path = shape(t).unwrap_or_else(|| PathData::new(vec![])); + let doc_segs = segments(&path).unwrap_or_default(); + f.push((1, dict(vec![(0, reals(&segs(&path)))]))); + data.push((0, V::Int(2))); + data.extend(vertical); + let (start, end) = t.kind.path_span().unwrap_or((0.0, 1.0)); + let n = doc_segs.len() as f64; + let s = place_on(&doc_segs, start); + // Round a closed path the end is the start plus the run, past the last segment when + // the run passes the path's start. + let e = if closed(&doc_segs) { + let run = (end - start).clamp(0.0, 1.0); + if run >= 1.0 - 1e-9 { s + n } else { place_on(&doc_segs, end.rem_euclid(1.0)) } + } else { + place_on(&doc_segs, end) + }; + data.push((6, reals(&[s, e]))); + } + } + f.push((2, dict(data))); + self.frames.push(dict(f)); + self.frames.len() - 1 + } + + /// Room for a story of type `t` named by `frames` text objects, within what the reader reads + /// of a story and of all the text objects' stories together: taken when there is. + fn room(&mut self, t: &TextObject, frames: usize) -> bool { + // The story as written: `\x03` (one byte) for a forced line break, and its last `\r`. + let len: usize = t.runs.iter().flat_map(|r| r.text.chars()).map(|c| if c == '\u{2028}' { 1 } else { c.len_utf8() }).sum::() + 1; + let total = self.text_bytes.saturating_add(len.saturating_mul(frames)); + let fits = len <= crate::import::MAX_STORY && frames <= crate::import::MAX_FRAMES && total <= crate::import::MAX_TEXT; + if fits { + self.text_bytes = total; + } + fits + } + + /// Type `t` as a story of one frame, `canvas` taking the document onto the canvas; its story + /// index, `None` past what the reader reads ([`Self::room`]). Lengths are written in document + /// points: a large canvas document is written at its scale already, and the reader scales the + /// text document's lengths with the art. Type that is scaled has its scale taken out of its + /// frame's matrix, or put into its frame or path, and put into its lengths. + pub(in crate::ai_editing) fn add(&mut self, t: &TextObject, canvas: Affine) -> Option { + let mark = self.mark(); + if !self.room(t, 1) { + return None; + } + let scale = story_scale(t); + let frame = self.frame(t, canvas, scale); + let story = self.story(t, &[frame], scale); + self.fits(mark).then_some(story) + } + + /// Where the frames, stories and fonts written so far end, and the bytes of the document they + /// make. + fn mark(&self) -> (usize, usize, usize, usize, usize) { + (self.frames.len(), self.stories.len(), self.doc_bytes, self.text_bytes, self.fonts.len()) + } + + /// Whether the frames and stories written since `mark` keep the text document within what the + /// reader reads of it (its ASCII85, a quarter of the bytes it decodes, with room for the + /// resources and settings); when they don't, they are taken out again. + fn fits(&mut self, (frames, stories, bytes, text, fonts_before): (usize, usize, usize, usize, usize)) -> bool { + // Room for the fonts, sheets and settings: bytes, and values. + const RESERVE: usize = 1 << 20; + const RESERVE_VALUES: usize = 1 << 16; + let (mut added, mut values) = (0, 0); + for v in self.frames.iter().skip(frames).chain(self.stories.iter().skip(stories)) { + let mut out = vec![]; + v.write(&mut out); + added += out.len() + 1; + values += v.count(); + } + let total = bytes.saturating_add(added); + let total_values = self.doc_values.saturating_add(values); + // The font set: each name in UTF-16 with its dictionary round it. + let fonts: usize = self.fonts.iter().map(|(n, _)| n.encode_utf16().count() * 2 + 64).sum(); + // ASCII85 writes five characters for every four bytes. + if total.saturating_add(RESERVE).saturating_add(fonts).saturating_mul(5) / 4 <= crate::import::MAX_TEXT_DOCUMENT / 4 + && total_values.saturating_add(RESERVE_VALUES) <= crate::import::MAX_TEXT_VALUES + { + (self.doc_bytes, self.doc_values) = (total, total_values); + return true; + } + self.frames.truncate(frames); + self.stories.truncate(stories); + // The fonts only the stories taken out named. + self.fonts.truncate(fonts_before); + self.text_bytes = text; + false + } + + /// The story and frame text object `id` of a thread is written as, planning the thread + /// `thread` (its frames in order, all area type the document holds, the first holding the + /// story) the first time one of its frames is written. `None` when the thread can't be written + /// as one story: `writable` says which frames are written where type can be text (every frame + /// of the thread must be). + pub(in crate::ai_editing) fn thread( + &mut self, + doc: &Document, + thread: &[NodeId], + id: NodeId, + canvas: Affine, + writable: &dyn Fn(NodeId) -> bool, + ) -> Option<(usize, usize)> { + let first = *thread.first()?; + if self.planned.insert(first) { + let members: Option> = thread.iter().map(|i| doc.node(*i)).collect(); + // The first frame holds the story; the others hold no text of their own. + // The frames of a story share one orientation (the reader reads no other). + let vertical = members.as_ref().and_then(|m| m.first()).is_some_and(|n| matches!(&n.kind, NodeKind::Text(t) if t.vertical)); + // The story has one scale for its lengths, so its frames share it. + let scale = match members.as_ref().and_then(|m| m.first()).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => story_scale(t), + _ => 1.0, + }; + let area = |n: &&Node| match &n.kind { + NodeKind::Text(t) => { + matches!(t.kind, TextKind::Area { .. }) + && t.vertical == vertical + && writable(n.id) + && t.wrap.is_empty() + && plain(n) + && t.ruby.is_empty() + && runs_held(t) + && (story_scale(t) / scale - 1.0).abs() <= 1e-9 + && within_reader(t, scale) + && shape(t).as_ref().and_then(segments).is_some_and(|s| closed(&s)) + } + _ => false, + }; + let ok = members.as_ref().is_some_and(|m| m.len() > 1 && m.iter().all(area)); + if let (true, Some(members)) = (ok, members) { + let texts: Vec<&TextObject> = + members.iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some(t.as_ref()) } else { None }).collect(); + let (whole, split_differs) = joined(&texts)?; + let mark = self.mark(); + if !whole.runs.iter().any(|r| r.text.chars().any(|c| !c.is_whitespace())) || !self.room(&whole, texts.len()) { + return None; + } + let lost = split_differs || texts.iter().any(|t| loses(t)); + let frames: Vec = texts.iter().map(|t| self.frame(t, canvas, scale)).collect(); + let story = self.story(&whole, &frames, scale); + if !self.fits(mark) { + return None; + } + self.lost |= lost; + for (k, i) in thread.iter().enumerate() { + self.threaded.insert(*i, (story, k)); + } + } + } + self.threaded.get(&id).copied() + } + + /// Whether a thread written since the last call has attributes the text document leaves out. + pub(in crate::ai_editing) fn take_lost(&mut self) -> bool { + std::mem::take(&mut self.lost) + } + + /// The text document as its section of the program: `%AI11_BeginTextDocument`, the ASCII85 of + /// the document on comment lines, `%AI11_EndTextDocument`. `doc` gives the text settings. + pub(in crate::ai_editing) fn section(&self, doc: &Document) -> String { + let mut bytes = vec![]; + let root: Vec<(u32, V)> = vec![(98, dict(vec![(0, V::Int(i64::from(STREAM_VERSION)))])), (0, self.resources()), (1, self.objects(doc))]; + for (i, (k, v)) in root.iter().enumerate() { + if i > 0 { + bytes.push(b' '); + } + let _ = write!(Bytes(&mut bytes), "/{k} "); + v.write(&mut bytes); + } + let packed: String = ps::ascii85(&bytes).chars().filter(|c| !c.is_whitespace()).collect(); + let mut s = String::from("%AI11_BeginTextDocument\n/AI11TextDocument : /ASCII85Decode ,\n"); + // Each line starts with `%`, and no line's data starts with `_` (a `%_` line is content). + let mut col = 0; + s.push('%'); + for ch in packed.chars() { + if col >= LINE && ch != '_' { + s.push_str("\n%"); + col = 0; + } + s.push(ch); + col += 1; + } + s.push_str("\n;\n%AI11_EndTextDocument\n"); + s + } + + fn resources(&self) -> V { + let mut fonts: Vec = self + .fonts + .iter() + .map(|(name, truetype)| { + dict(vec![(99, V::Name("CoolTypeFont")), (0, dict(vec![(0, V::Str(name.clone())), (2, V::Int(i64::from(*truetype)))]))]) + }) + .collect(); + fonts.push(dict(vec![(99, V::Name("CoolTypeFont")), (0, dict(vec![(0, V::Str(HIDDEN_GLYPH_FONT.into())), (2, V::Int(0))]))])); + let normal_char = dict(vec![(0, V::Str("[Normal Character Style]".into())), (6, Self::normal_char())]); + let normal_para = dict(vec![(0, V::Str("[Normal Paragraph Style]".into())), (5, Self::normal_para())]); + let empty = || set(vec![], false); + dict(vec![ + (1, set(fonts, true)), + (2, empty()), + (3, empty()), + (4, empty()), + (5, set(vec![normal_char], true)), + (6, set(vec![normal_para], true)), + (8, set(self.frames.iter().map(clone_v).collect(), false)), + (9, empty()), + ]) + } + + /// The character attributes stories start from. + fn normal_char() -> V { + dict(vec![ + (0, V::Int(0)), + (1, real(12.0)), + (4, V::Bool(true)), + (6, real(1.0)), + (7, real(1.0)), + (8, real(0.0)), + (9, real(0.0)), + (12, V::Int(0)), + (53, paint(&Color::gray(1.0))), + (56, V::Bool(true)), + (57, V::Bool(false)), + (63, real(1.0)), + ]) + } + + /// The paragraph attributes stories start from. + fn normal_para() -> V { + Self::para_features(&ParaStyle::default(), 1.0) + } + + fn objects(&self, doc: &Document) -> V { + let s = &doc.setup; + let hidden = (self.fonts.len()) as i64; + let mapping = V::List(HIDDEN_GLYPHS.iter().map(|(c, g)| dict(vec![(0, V::Str((*c).into())), (1, V::Str((*g).into()))])).collect()); + let q = &s.quotes; + let quotes = dict(vec![ + (0, V::Int(0)), + (1, V::Str(q.double[0].to_string())), + (2, V::Str(q.double[1].to_string())), + (3, V::Str(q.single[0].to_string())), + (4, V::Str(q.single[1].to_string())), + ]); + let pct = |v: f64| real(if v.is_finite() { v / 100.0 } else { 0.0 }); + let settings = dict(vec![ + (0, dict(vec![(0, V::Int(hidden)), (1, mapping)])), + (1, V::Int(0)), + (2, V::Int(0)), + (3, pct(s.superscript.size)), + (4, pct(s.superscript.position)), + (5, pct(s.subscript.size)), + (6, pct(s.subscript.position)), + (7, pct(s.small_caps_size)), + (8, V::Bool(s.typographers_quotes)), + (9, V::List(vec![quotes])), + (11, V::Int(6)), + (16, V::Bool(true)), + (17, V::Int(0)), + ]); + dict(vec![(0, settings), (1, V::List(self.stories.iter().map(clone_v).collect())), (2, Self::normal_char()), (3, Self::normal_para())]) + } +} + +/// A copy of `v`. +fn clone_v(v: &V) -> V { + match v { + V::Int(n) => V::Int(*n), + V::Real(r) => V::Real(*r), + V::Fine(r) => V::Fine(*r), + V::Small(r) => V::Small(*r), + V::Bool(b) => V::Bool(*b), + V::Str(s) => V::Str(s.clone()), + V::Name(n) => V::Name(n), + V::List(l) => V::List(l.iter().map(clone_v).collect()), + V::Dict(d) => V::Dict(d.iter().map(|(k, v)| (*k, clone_v(v))).collect()), + } +} + +/// The text object stub of story `story`, frame `frame`: `/AI11Text :`, `/FreeUndo`, `/FrameIndex`, +/// `/StoryIndex`, for area type and type on a path the frame's path (`/Art :`, `X=`, the path in +/// art space through `to_art`, `X+`, `; /ConfiningPath ,`), `/TextAntialiasing`, `;`. +pub(in crate::ai_editing) fn stub(s: &mut String, story: usize, frame: usize, t: &TextObject, to_art: Affine) { + let _ = writeln!(s, "/AI11Text :\n0 /FreeUndo ,\n{frame} /FrameIndex ,\n{story} /StoryIndex ,"); + let area = matches!(t.kind, TextKind::Area { .. }); + if let Some(path) = shape(t) { + s.push_str("/Art :\nX=\n"); + for sp in path.subpaths.iter().filter(|sp| !sp.anchors.is_empty()) { + crate::ai_ops::subpath(s, to_art, sp); + s.push_str(if sp.closed || area { "n\n" } else { "N\n" }); + } + s.push_str("X+\n; /ConfiningPath ,\n"); + } + s.push_str("2 /TextAntialiasing ,\n;\n"); +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 1b0ad8604..e0110f3de 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -71,8 +71,9 @@ use interp::{Fault, Interp}; use obj::PsError; pub(crate) use ai::MAX_GRADIENT_STOPS; -pub(crate) use native::scale_document; +pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, is_loss, layered_ai, layered_ai_from}; +pub(crate) use native::{MAX_TEXT, scale_document}; pub use text::family_style; /// The page of a PostScript file without a bounding box (US Letter). diff --git a/crates/eps/src/import/ai/frames.rs b/crates/eps/src/import/ai/frames.rs index 24f0b8b83..f6b00c44c 100644 --- a/crates/eps/src/import/ai/frames.rs +++ b/crates/eps/src/import/ai/frames.rs @@ -66,8 +66,8 @@ fn number_before(head: &[u8], key: &[u8]) -> Option { number(words(head.get(..k)?).last()?) } -/// A path in the format's path operators (`m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`; any other -/// operator ends the numbers before it). +/// A path in the format's path operators (`m`, `l`/`L`, `c`/`C`, `v`/`V`, `y`/`Y`, closed by `n`, +/// `f`, `s`, `b` or `h`; any other operator ends the numbers before it). fn path(text: &[u8]) -> Option { let mut bp = BezPath::new(); let mut nums: Vec = vec![]; @@ -106,6 +106,12 @@ fn path(text: &[u8]) -> Option { bp.curve_to(p(n - 4)?, q, q); cur = Some(q); } + // The painting operators that close the path first (lower case), and `h`: an area's frame + // is closed, its last point the first again. + b"n" | b"f" | b"s" | b"b" | b"h" if cur.is_some() => { + bp.close_path(); + cur = None; + } _ => {} } nums.clear(); @@ -135,4 +141,17 @@ mod tests { assert_eq!(f[&(49, 0)].bounding_box(), kurbo::Rect::new(1.0, 2.0, 3.0, 4.0)); assert!(text_frames(b"/AI11Text : garbage").is_empty()); } + + /// An area's frame painted with `n` (as the format writes it, the last point the first again) + /// is a closed path of its corners; one painted with `N` stays open. + #[test] + fn a_frame_painted_closed_is_closed() { + let doc = b"/AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r3 /StoryIndex ,\r/Art :\rX=\r40 130 m\r40 250 L\r240 250 L\r240 130 L\r40 130 L\rn\rX+\r; /ConfiningPath ,\r;\r\ + /AI11Text :\r0 /FreeUndo ,\r0 /FrameIndex ,\r4 /StoryIndex ,\r/Art :\rX=\r40 130 m\r40 250 L\r240 250 L\rN\rX+\r; /ConfiningPath ,\r;\r"; + let f = text_frames(doc); + let closed = vectorcraft_geom::PathData::from_bezpath(&f[&(3, 0)]); + assert!(closed.subpaths[0].closed); + assert_eq!(closed.subpaths[0].anchors.len(), 4, "the last point merges with the first"); + assert!(!vectorcraft_geom::PathData::from_bezpath(&f[&(4, 0)]).subpaths[0].closed); + } } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 7eb55ad11..87d224fbb 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -35,6 +35,7 @@ impl<'a> Reader<'a> { // The type takes the state's transparency, as a path does; an `Xy` after it sets the slot's own. n.opacity = gs.opacity; n.blend = gs.blend; + n.knockout_shape = gs.knockout_shape; let hidden = self.obj_hidden; self.add(n, hidden) } diff --git a/crates/eps/src/import/ate.rs b/crates/eps/src/import/ate.rs index ac5f38d6b..1e2347249 100644 --- a/crates/eps/src/import/ate.rs +++ b/crates/eps/src/import/ate.rs @@ -17,8 +17,8 @@ //! offset from it. A right-to-left paragraph (key `33` of its attributes) swaps the two: its //! "left"-aligned lines end at the anchor and its "right"-aligned ones start there. The frame of the //! story gives a matrix that carries that layout onto the canvas of the app, which is 16383 points a -//! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. See -//! [`Story::place`]. +//! side; the canvas's centre, 8191.5, is the centre of the file's `%AI3_TemplateBox`. A story without +//! a layout has its point type's anchor at its frame's point. See [`Story::place`]. use std::collections::BTreeMap; @@ -28,13 +28,13 @@ use vectorcraft_doc::{CharStyle, Justify, Node, NodeId, NodeKind, ParaDirection, use vectorcraft_geom::{Affine, PathData, Point, Rect}; /// Longest story read (bytes). -const MAX_STORY: usize = 1 << 20; +pub(crate) const MAX_STORY: usize = 1 << 20; /// Biggest text document read (bytes, decoded). -const MAX_BYTES: usize = 64 << 20; +pub(crate) const MAX_BYTES: usize = 64 << 20; /// Deepest nesting read. const MAX_DEPTH: usize = 64; /// Most values read. -const MAX_VALUES: usize = 4_000_000; +pub(crate) const MAX_VALUES: usize = 4_000_000; /// The centre of the app's canvas, in the units of its text document. pub(super) const CANVAS_CENTRE: f64 = 8191.5; @@ -241,8 +241,8 @@ const MAX_SHEETS: usize = 4096; const MAX_SHEETS_BASED_ON: usize = 16; /// Most frames read of one story, and most path segments of a frame. -const MAX_FRAMES: usize = 4096; -const MAX_SEGMENTS: usize = 1 << 16; +pub(crate) const MAX_FRAMES: usize = 4096; +pub(crate) const MAX_SEGMENTS: usize = 1 << 16; /// Where a frame puts its story's text. #[derive(Debug, Clone, PartialEq)] @@ -513,8 +513,17 @@ impl Texts { if indices.iter().any(|n| self.frame_vertical(*n) != vertical) { return None; } - // The first frame's layout, when the file keeps one: where its lines start. - let (mut anchor, mut lines, mut path_spacing) = ((0.0, 0.0), vec![], 0.0); + // The first frame's layout, when the file keeps one: where its lines start. Without one, the + // anchor of point type is its frame's point (key 0, where the layout's anchor is too). + let point = indices.first().and_then(|n| self.frames.get(*n)).and_then(|f| f.get("0")).and_then(|f| f.get("0")).and_then(Val::nums); + let (mut anchor, mut lines, mut path_spacing) = ( + match point.as_deref() { + Some([x, y]) if matches!(frames.first(), Some(FrameKind::Point(_))) => (*x, *y), + _ => (0.0, 0.0), + }, + vec![], + 0.0, + ); if let Some(f) = view.get("2").and_then(|l| find_node(l, "F")) { // Vertical type on a path turns its glyphs across the path: their matrices don't give // the spacing. @@ -854,8 +863,11 @@ impl Story { let text: String = if threaded { String::new() } else { + // The `\r` that ends the story's last paragraph; one before it ends a paragraph, and the + // empty paragraph after it is the story's last. self.text - .trim_end_matches('\r') + .strip_suffix('\r') + .unwrap_or(&self.text) .chars() .map(|c| match c { '\r' => '\n', diff --git a/crates/eps/src/import/native.rs b/crates/eps/src/import/native.rs index ed1b97616..f78bcc8a8 100644 --- a/crates/eps/src/import/native.rs +++ b/crates/eps/src/import/native.rs @@ -56,6 +56,7 @@ use compare::{ }; pub use layers::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, layered_ai, layered_ai_from}; pub(super) use layers::{layered, program_alone}; +pub(crate) use slots::MAX_TEXT; pub use slots::is_loss; #[cfg(test)] use slots::join_pieces; diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index 4924c167a..baff41dd8 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -43,7 +43,7 @@ pub(super) const MAX_SLOTS: usize = 5000; const MAX_PIECES: usize = 2000; /// Most bytes of text in the text objects of the layers (a story named twice counts twice). -pub(super) const MAX_TEXT: usize = 16 << 20; +pub(crate) const MAX_TEXT: usize = 16 << 20; /// Most pairs of a text object and a piece of the page's type compared, and of a glyph the page /// kept as outlines and a node of the page's type. @@ -286,11 +286,12 @@ pub(super) fn fill_slots( let Some(n) = nodes.get(i) else { break }; if let Some(story) = slot_of(n.name.as_deref()) { let slot = n.id; - let visible = n.visible; + let (visible, locked) = (n.visible, n.locked); let name = names.get(&n.id); let frame = ai::slot_frame(n.name.as_deref()); let state = (n.opacity, n.blend, n.isolate, n.knockout); - let transparent = see_through(n); + let shape = n.knockout_shape; + let transparent = see_through(n) || shape; // 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. @@ -307,6 +308,9 @@ pub(super) fn fill_slots( if !visible { Arc::make_mut(&mut p).visible = false; } + if locked { + Arc::make_mut(&mut p).locked = true; + } if let Some(name) = name.filter(|_| matches!(p.kind, NodeKind::Text(_))) { Arc::make_mut(&mut p).name = Some(name.clone()); } @@ -333,10 +337,11 @@ pub(super) fn fill_slots( } if let [p] = parts.as_mut_slice() { let p = Arc::make_mut(p); - (p.opacity, p.blend, p.isolate, p.knockout) = state; + (p.opacity, p.blend, p.isolate, p.knockout, p.knockout_shape) = (state.0, state.1, state.2, state.3, shape); } else if transparent { let mut group = Node::group(doc.alloc_id(), std::mem::take(&mut parts)); - (group.opacity, group.blend, group.isolate, group.knockout) = state; + (group.opacity, group.blend, group.isolate, group.knockout, group.knockout_shape) = + (state.0, state.1, state.2, state.3, shape); parts.push(Arc::new(group)); } } @@ -372,7 +377,7 @@ pub(super) fn fill_slots( if let Some(story) = story { made.push((story, frame, text.id)); } - text.visible = visible; + (text.visible, text.locked) = (visible, locked); // Type with an art style is what the page draws as the style's look: it is seen. let seen = seen || styled.is_some(); if let Some(appearance) = styled { @@ -384,7 +389,7 @@ pub(super) fn fill_slots( // Type the page shows is kept; other made type is weighed against the page. text.name = if seen { name.cloned() } else { Some(format!("{MADE}{}", name.map_or("", String::as_str))) }; // The type takes the slot's transparency. - (text.opacity, text.blend, text.isolate, text.knockout) = state; + (text.opacity, text.blend, text.isolate, text.knockout, text.knockout_shape) = (state.0, state.1, state.2, state.3, shape); if wrap.is_some() { text.extras_mut().wrap = wrap; } diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index c32ab0c59..dde26bc8d 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -3417,6 +3417,41 @@ fn type_keeps_the_opacity_and_blend_mode_of_its_text_object() { assert!(group.children().unwrap().iter().all(|p| (p.opacity, p.blend) == (1.0, BlendMode::Normal))); } +/// The knockout shape flag of a text object's `Xy` is its type's, made from the text document or +/// taken from the page, and is on the group of the two where the type comes with other art. +#[test] +fn type_keeps_the_knockout_shape_of_its_text_object() { + let shapes = |d: &Document| { + let mut v = vec![]; + d.walk(|n| { + if let NodeKind::Text(_) = &n.kind { + v.push(n.knockout_shape); + } + }); + v + }; + let doc = text_document(&[("Hi there\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let page = "0 0 0 1 setcmykcolor /Helvetica findfont 14 scalefont setfont 58.5 61.5 moveto (Hi there) show"; + for body in [format!("1 0.2 0 0 1 Xy\n{}", text_object(0)), format!("{}1 0.2 0 0 1 Xy\n", text_object(0))] { + let art = native_with_text(&layer("Art", true, &body), &doc); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(shapes(&d), [true], "{body}{notes:?}"); + let r = import(&eps(page, &art)).unwrap(); + assert_eq!(shapes(&r.document), [true], "{body}{:?}", r.warnings); + } + let art = native_with_text(&layer("Art", true, &format!("1 0.2 0 0 0 Xy\n{}", text_object(0))), &doc); + assert_eq!(shapes(&crate::ai_alone(&private(&art)).unwrap().0), [false]); + let page = format!( + "{}\n/Helvetica findfont 12 scalefont setfont 10 50 moveto (Hi) show\n10 50 moveto (Hi) false charpath 2 setlinewidth stroke", + page_square(10, 10) + ); + let words = layer("Words", true, "0 1 0 0 1 Xy\n/AI11Text :\n0 /FreeUndo ,\n;\n"); + let r = import(&eps(&page, &native(&(layer("Art", true, &square(10, 10)) + &words)))).unwrap(); + let words = &r.document.layers[1]; + assert_eq!(kinds(words), ["group[2]"], "{:?}", r.warnings); + assert!(words.children().unwrap()[0].knockout_shape); +} + #[test] fn a_commented_copy_of_a_story_that_has_a_plain_text_object_is_not_another_object() { // The file writes some stories twice (plain, then as comments) and others only as comments. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ed6825e0f..a63139f8a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -73,8 +73,15 @@ private editing data. VectorCraft: RGB or CMYK ink amounts, and their alpha channel) and linked images as placed files with their path, their folder relative to the document's and a preview; images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray, images of 16 bits a channel at 8 bits, and linked images in opacity - masks and pattern tiles as embedded images, each with a warning. Type, other patterns, symbols, other clipping - objects and live objects (placed documents included) are written as plain filled and stroked paths, and freeform gradients, gradients along + masks and pattern tiles as embedded images, each with a warning. Point type, area type (with its rows, columns, + gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for + each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, + scales, solid fills and strokes, capitals and paragraph alignment, indents, spacing and direction; other character + and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles and art cut to a + clipping object, mirrored type, turned or unevenly scaled area type and type on a path, area type that flows around a text + wrap, threads whose frames differ in orientation, type and stories past the sizes VectorCraft's reader reads, and + type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, symbols, other clipping objects and live + objects (placed documents included) are written as plain filled and stroked paths, and freeform gradients, gradients along or across a stroke and gradients without stops, with more than 256 stops or without extent as their average color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic gradients, and gradient stops linked to global process colors as their colors, each with a warning. The opacity