Merge branch 'main' of https://github.com/storytold/vectorcraft into 5421-line-arrowhead

# Conflicts:
#	docs/target-app-parity.md
This commit is contained in:
Julius van der Vaart
2026-11-09 23:42:42 +01:00
18 changed files with 986 additions and 34 deletions
+1
View File
@@ -30,6 +30,7 @@ use crate::import_shading::{
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
mod actual_text;
mod builder;
mod cid;
mod device;
+95
View File
@@ -0,0 +1,95 @@
//! Bounded ownership of authored replacement text (ISO 32000-1, 14.9.4).
use super::state::Builder;
use super::{Affine, Look, Placement, Shape, TextLine, Upright};
use crate::import_text::TextContent;
/// Only one replacement is retained: the outermost owner, even after it is consumed.
/// Depth is counted separately so ignored deep tags still unwind to the right owner.
#[derive(Default)]
pub(super) struct ActualText {
depth: usize,
owner: Option<usize>,
pending: Option<String>,
}
impl ActualText {
pub(super) fn begin(&mut self) {
self.depth = self.depth.saturating_add(1);
}
pub(super) fn end(&mut self) {
if self.owner == Some(self.depth) {
self.owner = None;
self.pending = None;
}
self.depth = self.depth.saturating_sub(1);
}
pub(super) fn active(&self) -> bool {
self.owner.is_some()
}
pub(super) fn set(&mut self, bytes: &[u8]) -> bool {
if self.active() {
return true;
}
// Bound before decoding: at most 64 KiB encoded / 192 KiB UTF-8, and no
// allocation per nested tag. Oversized/deep replacements use ordinary mappings.
if self.depth == 0 || self.depth > 128 || bytes.len() > 64 * 1024 {
return false;
}
self.owner = Some(self.depth);
self.pending = Some(crate::import_scan::text_string(bytes));
true
}
}
impl Builder<'_> {
/// The authored path never enters ordinary visual glyph reordering. The font was matched
/// against the glyph's original Unicode, not against this replacement's characters.
pub(super) fn actual_glyph(&mut self, o: &craft_pdf::interpret::font::OutlineGlyph, m: Affine, at: Placement, look: Look, opacity: f32) -> bool {
let key = o.font_cache_key();
self.flush_glyphs();
// A fill and stroke callback for the same glyph consumes its text only once.
if let (Some(stroke), Some(line)) = (&look.stroke, &mut self.text)
&& look.fill.is_none()
&& line.stroke_actual(key, at, stroke.clone(), look.overprint_stroke)
{
return true;
}
let width = o.advance_width().filter(|w| w.is_finite());
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
let top = width.filter(|w| w.abs() <= 1.0).map(|_| {
let vx = self.fonts.get(&key).and_then(|f| f.vertical.as_ref()).map_or(500.0, |v| v.centre(o.glyph_id().to_u32()));
Upright { top: m * kurbo::Point::new(vx, 880.0) }
});
// Keep pending until placement succeeds, including across a line flush.
let pending = self.actual.pending.take();
let content = match pending.as_deref() {
Some(text) if !text.is_empty() => TextContent::Actual(text),
_ => TextContent::Pen,
};
let place = |line: &mut TextLine| match top {
Some(top) => line.push_upright_content(&look, at, opacity, top, content),
None => line.push_content(&look, at, opacity, advance, content),
};
if !self.text.as_mut().is_some_and(place) {
self.flush_text();
let mut line = TextLine::new(at, opacity);
if !place(&mut line) {
self.actual.pending = pending;
return false;
}
self.text = Some(line);
}
let mut ink = o.outline();
if let Some(line) = &mut self.text
&& !ink.elements().is_empty()
{
ink.apply_affine(m);
line.ink.push(ink.bounding_box());
}
true
}
}
+20 -1
View File
@@ -23,9 +23,11 @@ impl<'p> Builder<'p> {
glyphs: None,
text: None,
text_as,
actual: Default::default(),
images: HashMap::new(),
image_keys: HashMap::new(),
cmyk_keys: HashSet::new(),
separation_paints: HashMap::new(),
opaque: HashSet::new(),
warnings: vec![],
fonts: HashMap::new(),
@@ -101,6 +103,7 @@ impl<'p> Builder<'p> {
self.mask = None;
self.masked.clear();
self.marked.clear();
self.actual = Default::default();
(self.scan, self.tag_at, self.aligned, self.paint_at) = (scan, 0, true, 0);
if self.scan.overprints && self.scan.zero_mode {
self.warn(OVERPRINT_ZERO_MODE);
@@ -582,6 +585,17 @@ impl<'p> Builder<'p> {
Some(Paint::Pattern { pattern: name, xf })
}
/// Separation image `st` (`w` × `h` pixels) as the paint of its ink at 100 % (a spot swatch, a
/// process plate or Registration, found by its colour space) and its tints as gray pixels
/// ([`crate::import_image::tint_gray`]). `None` for another image, an ink whose colour can't
/// be told, or tints that can't be read (with a warning): the image opens in RGB. The ink is
/// only marked used once the tints are read.
pub(super) fn separation_image(&mut self, st: &craft_pdf::syntax::object::Stream<'_>, w: u32, h: u32) -> Option<(Paint, Vec<u8>)> {
let space = self.colors.array_space(&crate::import_image::separation_space(st)?)?;
let gray = crate::import_image::tint_gray(st, w, h).map_err(|why| self.warn(why)).ok()?;
Some((self.colors.native(space, &[1.0])?.paint(), gray))
}
pub(super) fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) {
self.add_image_masked(key, make, xf, None, None);
}
@@ -737,6 +751,7 @@ impl<'p> Builder<'p> {
if self.text_as != TextAs::Text || self.nested > 0 {
return false;
}
let authored = self.actual.active();
let key = o.font_cache_key();
let glyph = o.glyph_id().to_u32();
let (unicode, from_cid) = match o.as_unicode().filter(|u| !replacement(u)) {
@@ -761,6 +776,7 @@ impl<'p> Builder<'p> {
let text: String = match unicode {
Some(BfString::Char(c)) => c.to_string(),
Some(BfString::String(s)) => s,
None if authored => String::new(),
None => {
// Invisible text isn't kept as outlines: the caller says it was left out.
if paint.is_some() && o.as_unicode().is_some_and(|u| replacement(&u)) {
@@ -773,7 +789,7 @@ impl<'p> Builder<'p> {
// Adobe's CID tables map some ideographs to their look-alike Kangxi radicals.
let text: String = if from_cid { text.chars().map(unify_radical).collect() } else { text };
let Some(at) = Placement::of(m) else { return false };
if text.is_empty() || text.chars().any(|c| c.is_control()) {
if !authored && (text.is_empty() || text.chars().any(|c| c.is_control())) {
return false;
}
let width = o.advance_width().filter(|w| w.is_finite());
@@ -837,6 +853,9 @@ impl<'p> Builder<'p> {
fill: if stroke.is_none() { paint } else { None },
stroke,
};
if authored {
return self.actual_glyph(o, m, at, look, opacity);
}
let advance = width.map_or(at.size * 0.5, |w| w as f64 / 1000.0 * at.size * at.h_scale / 100.0);
// A glyph set vertically is drawn from its origin, which the file puts the position
// vector's vx (from the font's `W2`, else half its width from `W` or `DW`) to the left of
+27
View File
@@ -250,6 +250,25 @@ impl<'a> Device<'a> for Builder<'_> {
let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None };
return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask, None);
}
// A Separation image keeps its ink: its tints as a grayscale image that the ink at
// 100 % colorizes, as a mask painted in the ink comes in. Its mask becomes an
// opacity mask, as a CMYK image's does. Read once per image: the decision is kept,
// so one that opens in RGB stays RGB, and the gray pixels have their own key.
if !white {
let found = match self.separation_paints.get(&key) {
Some(paint) => paint.clone().map(|p| (p, None)),
None => {
let read = self.separation_image(st, w, h);
self.separation_paints.insert(key, read.as_ref().map(|(p, _)| p.clone()));
read.map(|(p, gray)| (p, Some(gray)))
}
};
if let Some((paint, gray)) = found {
let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None };
let make = || gray.and_then(|g| gray_png(g, w, h)).map(|png| (ImageBlob::new("image/png", png), w, h));
return self.add_image_masked(key ^ 0x5e9_a7e5, make, transform, mask, Some(paint));
}
}
// JPEG passthrough for plain DeviceRGB/DeviceGray DCT images.
let dict = st.dict();
let filters = st.filters();
@@ -359,6 +378,7 @@ impl<'a> Device<'a> for Builder<'_> {
if self.nested > 0 {
return;
}
self.actual.begin();
let at = self.tag_at;
self.tag_at += 1;
let group = match self.scan.tags.get(at) {
@@ -397,5 +417,12 @@ impl<'a> Device<'a> for Builder<'_> {
self.flush();
}
self.marked.pop();
self.actual.end();
}
fn actual_text(&mut self, text: &[u8]) {
if self.nested == 0 && self.text_as == crate::TextAs::Text && !self.actual.set(text) {
self.warn("ActualText beyond the import size or nesting limit was left to glyph mappings");
}
}
}
+4
View File
@@ -82,10 +82,14 @@ pub(super) struct Builder<'p> {
pub(super) glyphs: Option<GlyphRun>,
pub(super) text: Option<TextLine>,
pub(super) text_as: TextAs,
pub(super) actual: super::actual_text::ActualText,
pub(super) images: HashMap<String, ImageBlob>,
pub(super) image_keys: HashMap<u128, (String, u32, u32)>,
/// The images kept with their CMYK samples ([`crate::import_image`]).
pub(super) cmyk_keys: HashSet<u128>,
/// Images in a Separation space, by key: the paint of their ink, or `None` when they open in
/// RGB (an ink that can't be told, tints that can't be read).
pub(super) separation_paints: HashMap<u128, Option<Paint>>,
/// Mask keys of CMYK images whose mask turned out to hide nothing.
pub(super) opaque: HashSet<u128>,
pub(super) warnings: Vec<String>,
+12
View File
@@ -239,6 +239,18 @@ impl<'p> Colors<'p> {
}
}
/// The space of colour space array `a` (`[/Separation name alt tint]` or `[/DeviceN …]`, such
/// as an image's) among the file's: matched by its kind, its colorant names and its tint
/// transform, not by the page's walk. `None` for another array.
pub fn array_space(&mut self, a: &Array<'_>) -> Option<Space> {
self.scan();
let mut it = a.flex_iter();
let (device_n, names) = ink_names(&mut it)?;
it.next::<Object<'_>>()?;
let key = debug_text(&Function::new(&it.next::<Object<'_>>()?)?)?;
self.spaces.iter().position(|s| s.device_n == device_n && s.names == names && s.key == key).map(Space::Inks)
}
/// Whether a space of inks `names` has the kind, colorant count and tint transform of a space
/// of other inks. [`Colors::space_of`] tells such spaces apart only by the names from the
/// page's walk.
+73 -3
View File
@@ -3,11 +3,12 @@
//! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any
//! other is decoded, its Decode array applied, and stored as a CMYK TIFF
//! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer
//! carries it as the image's opacity mask ([`has_mask`]). Every other image is read as RGB by the
//! interpreter.
//! carries it as the image's opacity mask ([`has_mask`]). A Separation image keeps its ink: its
//! tints are read as a grayscale image ([`tint_gray`]) that the ink colorizes. Every other image is
//! read as RGB by the interpreter.
use craft_pdf::syntax::Filter;
use craft_pdf::syntax::object::stream::ImageDecodeParams;
use craft_pdf::syntax::object::stream::{ImageColorSpace, ImageDecodeParams};
use craft_pdf::syntax::object::{Array, Dict, Name, Stream};
use vectorcraft_doc::ImageBlob;
use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components};
@@ -90,6 +91,75 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<Im
Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)).map(Some).ok_or(UNREAD)
}
/// An image in a spot ink read as RGB: why.
pub(crate) const UNREAD_TINTS: &str = "images in a spot ink whose samples couldn't be read were imported in RGB";
/// The image's colour space when it is a Separation (`[/Separation name alt tint]`): one ink.
/// `None` for the ink `None`, which paints nothing (ISO 32000-1 §8.6.6.4): the interpreter draws it.
pub(crate) fn separation_space<'a>(st: &Stream<'a>) -> Option<Array<'a>> {
let d = st.dict();
let a = d.get::<Array<'a>>(key(d, b"ColorSpace", b"CS"))?;
let one_ink = {
let mut it = a.flex_iter();
it.next::<Name<'_>>()?.as_ref() == b"Separation" && it.next::<Name<'_>>()?.as_ref() != b"None"
};
one_ink.then_some(a)
}
/// The tints of the one-component image `st` (`width` × `height` pixels, a Separation image's)
/// as a grayscale image: white where it lays no ink, black at 100 %, its Decode array applied.
/// `Err` (a warning) when its samples can't be read.
pub(crate) fn tint_gray(st: &Stream<'_>, width: u32, height: u32) -> Result<Vec<u8>, &'static str> {
let d = st.dict();
let bpc = d.get::<u8>(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8);
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
return Err(UNREAD_TINTS);
}
// The same cap as CMYK images'.
let n = u64::from(width) * u64::from(height);
if n == 0 || n > MAX_CMYK_PIXELS {
return Err(UNREAD_TINTS);
}
let (lo, hi) = match d.get::<Array<'_>>(key(d, b"Decode", b"D")).map(|a| a.iter::<f32>().collect::<Vec<_>>()).as_deref() {
Some(&[lo, hi]) if lo.is_finite() && hi.is_finite() => (lo, hi),
_ => (0.0, 1.0),
};
let params = ImageDecodeParams { is_indexed: false, bpc: Some(bpc), num_components: Some(1), target_dimension: None, width, height };
let decoded = st.decoded_image(&params).map_err(|_| UNREAD_TINTS)?;
// An image codec (JPEG, CCITT, JBIG2, JPEG 2000) says what it decoded to: one gray channel of
// the image's size, at its own bit depth.
let bpc = match &decoded.image_data {
None => bpc,
Some(i)
if i.alpha.is_none()
&& matches!(i.color_space, Some(ImageColorSpace::Gray))
&& (i.width, i.height) == (width, height)
&& matches!(i.bits_per_component, 1 | 2 | 4 | 8 | 16) =>
{
i.bits_per_component
}
Some(_) => return Err(UNREAD_TINTS),
};
let bits = usize::from(bpc);
let row = (width as usize * bits).div_ceil(8);
let data = decoded.data.get(..row * height as usize).ok_or(UNREAD_TINTS)?;
let max = ((1u32 << bits) - 1) as f32;
let mut out = Vec::with_capacity(n as usize);
for line in data.chunks_exact(row) {
for x in 0..width as usize {
let v = match bits {
16 => line.get(x * 2..x * 2 + 2).map_or(0, |s| s.iter().fold(0u32, |v, b| (v << 8) | u32::from(*b))),
8 => line.get(x).map_or(0, |b| u32::from(*b)),
// 1, 2 or 4 bits a sample, packed from the high bits of each byte.
_ => line.get(x * bits / 8).map_or(0, |b| (u32::from(*b) >> (8 - bits - (x * bits) % 8)) & ((1 << bits) - 1)),
};
let tint = (lo + v as f32 / max * (hi - lo)).clamp(0.0, 1.0);
out.push(((1.0 - tint) * 255.0).round() as u8);
}
}
Ok(out)
}
/// Does the image carry its own transparency (a soft mask, a stencil mask or a colour key)?
pub(crate) fn has_mask(st: &Stream<'_>) -> bool {
let d = st.dict();
+138 -16
View File
@@ -146,6 +146,27 @@ fn common_gap(gaps: &[Gap]) -> f64 {
if *common > 0.5 { 0.0 } else { common.max(0.0) }
}
/// A mapped glyph, authored logical text, or a glyph whose replacement was already placed.
#[derive(Clone, Copy)]
pub(crate) enum TextContent<'a> {
Mapped(&'a str),
Actual(&'a str),
Pen,
}
impl<'a> TextContent<'a> {
fn text(self) -> &'a str {
match self {
Self::Mapped(text) | Self::Actual(text) => text,
Self::Pen => "",
}
}
fn authored(self) -> bool {
!matches!(self, Self::Mapped(_))
}
}
/// A line of type being gathered: runs of glyphs on one baseline.
pub(crate) struct TextLine {
/// The first glyph's placement.
@@ -173,6 +194,10 @@ pub(crate) struct TextLine {
/// glyph has its glyph's [`Slot`]; a space read from a gap and the other characters of a
/// glyph of several have none.
slots: Vec<Option<Slot>>,
/// Per character: authored logical order must survive visual-to-logical reconstruction.
authored: Vec<bool>,
/// Fill callbacks in an authored span, for the reader's later stroke pass.
actual_fills: HashMap<(u128, u64, u64), Option<usize>>,
/// The first glyph was set vertically ([`Upright`]).
upright: Option<Upright>,
/// The glyphs make a column (vertical type), each below the last.
@@ -232,6 +257,8 @@ impl TextLine {
runs: vec![],
text_bytes: 0,
slots: vec![],
authored: vec![],
actual_fills: HashMap::new(),
upright: None,
column: false,
ink: vec![],
@@ -251,10 +278,24 @@ impl TextLine {
/// line's single glyph a column): below the last one, in the same column. `false`: it starts
/// another line.
pub fn push_upright(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, text: &str) -> bool {
self.push_upright_content(look, at, opacity, upright, TextContent::Mapped(text))
}
pub fn push_upright_content(&mut self, look: &Look, at: Placement, opacity: f32, upright: Upright, content: TextContent<'_>) -> bool {
let text = content.text();
if !text.is_empty() && self.authored.is_empty() && self.upright.is_some() {
// Suppressed vertical glyphs likewise provide no text anchor.
self.at = at;
self.opacity = opacity;
self.runs.clear();
self.column = false;
}
let down = -self.at.up();
let size = self.at.size.max(at.size);
let Some((last, _, run)) = self.runs.last_mut() else {
self.runs.push((look.clone(), 0.0, text.to_string()));
self.record_actual_fill(look, at, content);
self.authored.extend(text.chars().map(|_| content.authored()));
(self.upright, self.next, self.last) = (Some(upright), at.origin + down * at.size, at.origin);
return true;
};
@@ -276,14 +317,20 @@ impl TextLine {
self.column = true;
// Letter-spaced Japanese is common: a gap reads as a space only when a whole em is left
// out (the tracking set in `finish` keeps narrower gaps).
if step > size * 0.9 && !run.ends_with(is_space) && !text.starts_with(is_space) {
if !matches!(content, TextContent::Pen) && step > size * 0.9 && !run.is_empty() && !run.ends_with(is_space) && !text.starts_with(is_space) {
run.push(' ');
self.authored.push(false);
}
if last.takes(look) {
if last.takes(look)
&& (matches!(content, TextContent::Pen)
|| (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false)))
{
run.push_str(text);
} else {
self.runs.push((look.clone(), 0.0, text.to_string()));
}
self.record_actual_fill(look, at, content);
self.authored.extend(text.chars().map(|_| content.authored()));
self.next = at.origin + down * at.size;
self.last = at.origin;
true
@@ -292,10 +339,28 @@ impl TextLine {
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
/// it continues this line; `false`: it starts another.
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
self.push_content(look, at, opacity, advance, TextContent::Mapped(text))
}
pub fn push_content(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, content: TextContent<'_>) -> bool {
let text = content.text();
let pen = matches!(content, TextContent::Pen);
// A vertical line (even a single upright glyph) takes no horizontal glyphs.
if self.column || self.upright.is_some() {
return false;
}
if !pen && self.text_bytes == 0 && !self.baseline.is_empty() {
// A line containing only suppressed glyphs has no text anchor yet. Its first
// real character starts at its own origin, including after a line flush.
self.at = at;
self.opacity = opacity;
self.next = at.origin;
self.last = at.origin;
self.last_dir = at.dir;
self.last_size = at.size;
self.baseline.clear();
self.runs.clear();
}
let bytes = self.text_bytes;
let glyph = self.baseline.len();
let curved = self.curved();
@@ -334,12 +399,12 @@ impl TextLine {
if !on_line && !on_curve {
return false;
}
if !run.ends_with(is_space) && !text.starts_with(is_space) {
if !pen && !run.is_empty() && !run.ends_with(is_space) && !text.starts_with(is_space) {
self.gaps.push(Gap { at: bytes, width: gap, em: self.last_size.min(at.size), on_line });
}
// A space the file draws between two glyphs spans the gaps on both sides of it.
let drawn = self.drawn_space.take();
if on_line {
let drawn = if pen { None } else { self.drawn_space.take() };
if on_line && !pen {
let mut chars = text.chars();
if chars.next().is_some_and(is_space) && chars.next().is_none() && !run.ends_with(is_space) {
self.drawn_space = Some((bytes, gap + advance));
@@ -347,7 +412,10 @@ impl TextLine {
self.drawn_spaces.push((b, before + gap));
}
}
if last.takes(look) && (*last_shift - shift).abs() < 1e-3 {
if last.takes(look)
&& (*last_shift - shift).abs() < 1e-3
&& (pen || (!matches!(content, TextContent::Actual(_)) && !self.authored.last().copied().unwrap_or(false)))
{
run.push_str(text);
} else {
self.runs.push((look.clone(), shift, text.to_string()));
@@ -355,12 +423,18 @@ impl TextLine {
} else {
self.runs.push((look.clone(), 0.0, text.to_string()));
}
self.record_actual_fill(look, at, content);
self.text_bytes = self.text_bytes.saturating_add(text.len());
let one = text.chars().count() == 1;
self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) }));
self.slots.extend(text.chars().skip(1).map(|_| None));
if !pen {
let one = text.chars().count() == 1 && !content.authored();
self.slots.push(Some(Slot { glyph, advance: one.then_some(advance) }));
self.slots.extend(text.chars().skip(1).map(|_| None));
self.authored.extend(text.chars().map(|_| content.authored()));
}
// Pen-only glyphs retain a baseline but no character slot: later text still points
// to its own glyph, even after several suppressed glyphs or a style change.
self.next = at.origin + at.dir * advance;
if !text.trim().is_empty() {
if pen || !text.trim().is_empty() {
self.visible = self.next;
}
self.last = at.origin;
@@ -370,6 +444,25 @@ impl TextLine {
true
}
fn record_actual_fill(&mut self, look: &Look, at: Placement, content: TextContent<'_>) {
if content.authored() && look.fill.is_some() {
let run = matches!(content, TextContent::Actual(_)).then(|| self.runs.len().saturating_sub(1));
self.actual_fills.insert((look.font, at.origin.x.to_bits(), at.origin.y.to_bits()), run);
}
}
/// The reader fills a whole Tj/TJ before replaying its glyphs as strokes. A replay
/// must neither consume the replacement nor rewind the pen. Only the first glyph's
/// style belongs to the replacement; suppressed glyphs have no text to restyle.
pub fn stroke_actual(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool {
let key = (font, at.origin.x.to_bits(), at.origin.y.to_bits());
let Some(run) = self.actual_fills.remove(&key) else { return false };
if let Some((look, ..)) = run.and_then(|i| self.runs.get_mut(i)) {
(look.stroke, look.overprint_stroke) = (Some(stroke), overprint);
}
true
}
/// Has the baseline turned (more than about 3° from the first glyph to the last)?
fn curved(&self) -> bool {
self.last_dir.dot(self.at.dir) < 0.9986
@@ -416,16 +509,27 @@ impl TextLine {
// Turned along a curve: type on a path through the glyphs.
let path = if self.curved() { self.baseline_path() } else { None };
facts.wraps = path.is_none() && self.upright.is_none() && self.runs.last().is_some_and(|(.., t)| t.ends_with(' '));
if let Some((.., last)) = self.runs.last_mut() {
last.truncate(last.trim_end().len());
if !self.authored.last().copied().unwrap_or(false)
&& let Some((.., last)) = self.runs.last_mut()
{
// Trim only ordinary trailing spaces, never authored replacement characters.
let trim = last
.chars()
.rev()
.zip(self.authored.iter().rev())
.take_while(|(c, authored)| c.is_whitespace() && !**authored)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
last.truncate(last.len().saturating_sub(trim));
}
self.runs.retain(|(.., t)| !t.is_empty());
if self.runs.iter().all(|(.., t)| t.trim().is_empty()) {
if self.runs.iter().all(|(.., t)| t.is_empty()) || (!self.authored.contains(&true) && self.runs.iter().all(|(.., t)| t.trim().is_empty())) {
return None;
}
// The spaces trimmed off the end have no place either.
let chars: usize = self.runs.iter().map(|(.., t)| t.chars().count()).sum();
self.slots.truncate(chars);
self.authored.truncate(chars);
// The baseline most of the characters sit on is the line's: the others are shifted off it
// (a line starting with a raised footnote mark keeps the baseline of its text).
let mut on: Vec<(f64, usize)> = vec![];
@@ -493,6 +597,7 @@ impl TextLine {
let char_at = earlier + head.chars().count();
if char_at <= self.slots.len() {
self.slots.insert(char_at, None);
self.authored.insert(char_at, false);
}
run.text.insert(at, ' ');
spaces.push((gap.at, gap.width, gap.on_line));
@@ -502,7 +607,7 @@ impl TextLine {
gaps.extend(self.drawn_spaces.iter().map(|&(b, width)| (b + spaces.iter().filter(|s| s.0 < b).count(), width)));
gaps.sort_by_key(|&(b, _)| b);
if self.upright.is_none() {
to_logical(&mut t);
to_logical(&mut t, &self.authored);
}
let angle = self.at.dir.atan2();
// The glyphs' tops lean along the baseline (+x), up being -y in the type's own space.
@@ -1055,9 +1160,26 @@ fn is_style(s: &str) -> bool {
/// Hebrew or Arabic comes from a PDF in visual order (each glyph where it is drawn): put `t`'s text
/// back in logical order, with the paragraph direction that shows it as drawn.
fn to_logical(t: &mut TextObject) {
let Some((order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return };
fn to_logical(t: &mut TextObject, authored: &[bool]) {
let Some((mut order, rtl)) = vectorcraft_text::logical_order(&t.plain_text()) else { return };
let chars: Vec<(char, usize)> = t.runs.iter().enumerate().flat_map(|(i, r)| r.text.chars().map(move |c| (c, i))).collect();
if authored.contains(&true) {
// Explicit spans are logical-order islands. Reconstruct each ordinary stretch on
// either side independently; never reverse authored Arabic or its joiners again.
order.clear();
let mut start = 0;
while start < chars.len() {
let actual = authored.get(start).copied().unwrap_or(false);
let end = (start + 1..chars.len()).find(|&i| authored.get(i).copied().unwrap_or(false) != actual).unwrap_or(chars.len());
let text: String = chars.get(start..end).unwrap_or_default().iter().map(|(c, _)| c).collect();
if !actual && let Some((local, _)) = vectorcraft_text::logical_order(&text) {
order.extend(local.into_iter().map(|i| start + i));
} else {
order.extend(start..end);
}
start = end;
}
}
let mut runs: Vec<(usize, String)> = Vec::with_capacity(t.runs.len());
for &(c, i) in order.iter().filter_map(|&k| chars.get(k)) {
match runs.last_mut() {
+1
View File
@@ -8,6 +8,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
use crate::*;
mod actual_text;
mod blend;
mod charstroke;
mod cmykblend;
+309
View File
@@ -0,0 +1,309 @@
//! Original, synthetic PDF content; no external fonts needed for semantic coverage.
use crate::{ImportOptions, ImportReport, TextAs, import_with_report};
use vectorcraft_doc::{NodeKind, TextObject};
fn stream(entries: &str, content: &[u8]) -> Vec<u8> {
let mut bytes = format!("<< /Length {} {entries} >>\nstream\n", content.len()).into_bytes();
bytes.extend_from_slice(content);
bytes.extend_from_slice(b"\nendstream");
bytes
}
fn document(objects: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![0];
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend(format!("{} 0 obj\n", i + 1).bytes());
bytes.extend_from_slice(object);
bytes.extend_from_slice(b"\nendobj\n");
}
let xref = bytes.len();
bytes.extend(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.len()).bytes());
for offset in offsets.iter().skip(1) {
bytes.extend(format!("{offset:010} 00000 n \n").bytes());
}
bytes.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", offsets.len()).bytes());
bytes
}
fn pdf(pages: &[&str]) -> Vec<u8> {
let kids = (0..pages.len()).map(|i| format!("{} 0 R", 4 + i * 2)).collect::<Vec<_>>().join(" ");
let mut objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
format!("<< /Type /Pages /Kids [{kids}] /Count {} >>", pages.len()).into_bytes(),
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
];
for (i, content) in pages.iter().enumerate() {
objects.push(format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F 3 0 R >> /Properties << /Named << /ActualText (Named) >> >> >> /Contents {} 0 R >>", 5 + i * 2).into_bytes());
objects.push(stream("", format!("BT /F 20 Tf 1 0 0 1 20 700 Tm {content} ET").as_bytes()));
}
document(&objects)
}
fn open(content: &str) -> ImportReport {
import_with_report(&pdf(&[content]), &ImportOptions::default()).unwrap()
}
// Geometry assertions need the PDF and layout to use the same widths. Unembedded
// Helvetica is not installed on every platform; missing-font layout fits the overall
// line length, so removing characters changes even an ordinary suffix's position.
fn open_positioned(content: &str) -> ImportReport {
let data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
let face = vectorcraft_text::FontDb::global().find_postscript("SourceSans3-Regular").unwrap();
assert_eq!(face.file_data(), data);
let widths = (32u8..=126)
.map(|c| {
let glyph = face.glyph_for(char::from(c));
assert_ne!(glyph, 0);
(face.advance(glyph) * 1000.0 / face.units_per_em()).to_string()
})
.collect::<Vec<_>>()
.join(" ");
let font = format!(
"<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R /FirstChar 32 /LastChar 126 /Widths [{widths}] >>"
);
let descriptor = b"<< /Type /FontDescriptor /FontName /SourceSans3-Regular /Flags 32 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1000 /Descent -300 /CapHeight 700 /StemV 80 /FontFile2 7 0 R >>".to_vec();
let bytes = custom_pdf(content, font.as_bytes(), "", &[descriptor, stream("", data)]);
import_with_report(&bytes, &ImportOptions::default()).unwrap()
}
fn texts(report: &ImportReport) -> Vec<TextObject> {
let mut result = vec![];
report.document.walk(|node| {
if let NodeKind::Text(text) = &node.kind {
result.push((**text).clone());
}
});
result
}
fn plain(report: &ImportReport) -> String {
texts(report).iter().map(TextObject::plain_text).collect()
}
fn utf16(text: &str) -> String {
format!("<FEFF{}>", text.encode_utf16().map(|u| format!("{u:04X}")).collect::<String>())
}
fn character_origin(report: &ImportReport, character: char) -> kurbo::Point {
for text in texts(report) {
let plain = text.plain_text();
if let Some(byte) = plain.find(character) {
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text);
let glyph = layout.glyphs.iter().find(|g| g.byte == byte).unwrap();
return text.xf * glyph.origin;
}
}
panic!("missing {character}");
}
#[test]
fn actual_text_replaces_once_and_keeps_suffix_position() {
let replaced = open_positioned("/Span << /ActualText (AB) >> BDC (xy) Tj EMC (C) Tj");
let ordinary = open_positioned("(xyC) Tj");
assert_eq!(plain(&replaced), "ABC");
let delta = character_origin(&replaced, 'C') - character_origin(&ordinary, 'C');
assert!(delta.hypot() < 0.05, "suffix moved: {delta:?}");
}
#[test]
fn actual_text_empty_and_nested_owners() {
for (content, expected) in [
("(P) Tj /Span << /ActualText () >> BDC (xy) Tj EMC (C) Tj", "PC"),
("/Span << /ActualText (AB) >> BDC /Span << /ActualText (INNER) >> BDC (x) Tj EMC (y) Tj EMC (C) Tj", "ABC"),
("/Span << /ActualText () >> BDC /Span << /ActualText (INNER) >> BDC (xy) Tj EMC EMC (C) Tj", "C"),
("/Span BMC /Span << /ActualText (AB) >> BDC (xy) Tj EMC EMC (C) Tj", "ABC"),
("/Span << /ActualText (A) >> BDC (x) Tj EMC /Span << /ActualText (B) >> BDC (y) Tj EMC (C) Tj", "ABC"),
] {
assert_eq!(plain(&open(content)), expected, "{content}");
}
}
#[test]
fn actual_text_decodes_pdf_strings_and_preserves_authored_order() {
for (encoded, expected) in [
(utf16("ل\u{200d}ا\u{200c}ب"), "ل\u{200d}ا\u{200c}ب"),
("<8093A0>".into(), "•fi€"),
("<EFBBBF4162>".into(), "Ab"),
("( A )".into(), " A "),
("( )".into(), " "),
(utf16("A\nB"), "A\nB"),
] {
assert_eq!(plain(&open(&format!("/Span << /ActualText {encoded} >> BDC (xy) Tj EMC"))), expected);
}
assert_eq!(plain(&open("/Span /Named BDC (xy) Tj EMC")), "Named");
}
#[test]
fn actual_text_across_operations_styles_and_line_flushes() {
let report = open_positioned("/Span << /ActualText (AB) >> BDC (x) Tj [(y) -400 (z)] TJ 1 0 0 rg /F 24 Tf (w) Tj EMC (C) Tj");
assert_eq!(plain(&report), "ABC");
let report = open_positioned("/Span << /ActualText (AB) >> BDC (x) Tj 0 -40 Td (y) Tj EMC (C) Tj");
assert_eq!(plain(&report), "ABC");
let ordinary = open_positioned("(x) Tj 0 -40 Td (yC) Tj");
assert!((character_origin(&report, 'C') - character_origin(&ordinary, 'C')).hypot() < 0.05);
// The replacement belongs to the first placed glyph's style and line.
assert_eq!(texts(&report)[0].plain_text(), "AB");
}
#[test]
fn actual_text_fill_and_stroke_is_not_duplicated() {
let report = open("2 Tr /Span << /ActualText (AB) >> BDC [(xy) 0 (zw)] TJ EMC (C) Tj");
assert_eq!(plain(&report), "ABC");
assert!(texts(&report).iter().flat_map(|t| &t.runs).all(|r| r.style.has_stroke()));
}
#[test]
fn actual_text_page_reset_and_unbalanced_end() {
let bytes = pdf(&["/Span << /ActualText (AB) >> BDC (xy) Tj", "EMC (C) Tj"]);
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
assert_eq!(plain(&report), "ABC");
}
#[test]
fn actual_text_outlines_are_unchanged() {
let outline = |content| {
let report = import_with_report(&pdf(&[content]), &ImportOptions { text_as: TextAs::Outlines, ..Default::default() }).unwrap();
assert!(texts(&report).is_empty());
let mut paths = vec![];
report.document.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
paths.push(path.to_bezpath());
}
});
paths
};
assert_eq!(outline("/Span << /ActualText (AB) >> BDC (xy) Tj EMC"), outline("(xy) Tj"));
}
#[test]
fn actual_text_bounded_replacements_recover_siblings() {
let oversized = "A".repeat(64 * 1024 + 1);
let report = open(&format!("/Span << /ActualText ({oversized}) >> BDC (x) Tj EMC /Span << /ActualText (B) >> BDC (y) Tj EMC"));
assert_eq!(plain(&report), "xB");
assert!(report.warnings.iter().any(|w| w.contains("ActualText beyond")));
let deep = format!(
"{} /Span << /ActualText (ignored) >> BDC (x) Tj EMC {} /Span << /ActualText (B) >> BDC (y) Tj EMC",
"/Span BMC ".repeat(128),
"EMC ".repeat(128)
);
assert_eq!(plain(&open(&deep)), "xB");
}
#[test]
fn actual_text_survives_reader_windows() {
let content = format!("/Span << /ActualText (AB) >> BDC (x) Tj {} (y) Tj EMC (C) Tj", "q Q ".repeat(20_000));
assert_eq!(plain(&open(&content)), "ABC");
}
fn custom_pdf(content: &str, font: &[u8], resources: &str, extra: &[Vec<u8>]) -> Vec<u8> {
let mut objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [4 0 R] /Count 1 >>".to_vec(),
font.to_vec(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << /Font << /F 3 0 R >> {resources} >> /Contents 5 0 R >>")
.into_bytes(),
stream("", format!("BT /F 20 Tf 1 0 0 1 20 700 Tm {content} ET").as_bytes()),
];
objects.extend_from_slice(extra);
document(&objects)
}
#[test]
fn actual_text_mixed_line_still_reconstructs_ordinary_rtl() {
let map = stream("", b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <00> <FF> endcodespacerange 4 beginbfchar <78> <05DD> <79> <05D5> <7A> <05DC> <77> <05E9> endbfchar endcmap end end");
let content = format!("(xyzw) Tj /Span << /ActualText {} >> BDC (xy) Tj EMC (xyzw) Tj", utf16("ل\u{200d}ا"));
let bytes = custom_pdf(&content, b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /ToUnicode 6 0 R >>", "", &[map]);
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
assert_eq!(texts(&report).len(), 1);
assert_eq!(plain(&report), "שלוםل\u{200d}اשלום");
}
#[test]
fn actual_text_rescues_a_glyph_without_unicode() {
// Identity-H with no ToUnicode or embedded font: CID 65535 has no character mapping.
let font = b"<< /Type /Font /Subtype /Type0 /BaseFont /Unmapped /Encoding /Identity-H /DescendantFonts [6 0 R] >>";
let descendant = b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Unmapped /CIDSystemInfo << /Registry (Unknown) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 500 >>".to_vec();
let descriptor = b"<< /Type /FontDescriptor /FontName /Unmapped /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >>".to_vec();
let extra = [descendant, descriptor];
let import = |content| import_with_report(&custom_pdf(content, font, "", &extra), &ImportOptions::default()).unwrap();
assert!(plain(&import("3 Tr <FFFF> Tj")).is_empty(), "the fixture really has no usable mapping");
assert_eq!(plain(&import("3 Tr /Span << /ActualText (AB) >> BDC <FFFFFFFF> Tj EMC")), "AB");
}
#[test]
fn actual_text_matches_embedded_glyph_identity_not_replacement() {
let font = b"<< /Type /Font /Subtype /TrueType /BaseFont /SourceSans3-Regular /Encoding /WinAnsiEncoding /FontDescriptor 6 0 R /FirstChar 120 /LastChar 121 /Widths [500 500] >>";
let descriptor = b"<< /Type /FontDescriptor /FontName /SourceSans3-Regular /Flags 32 /FontBBox [-500 -300 1500 1000] /ItalicAngle 0 /Ascent 1000 /Descent -300 /CapHeight 700 /StemV 80 /FontFile2 7 0 R >>".to_vec();
let data = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
let bytes = custom_pdf("/Span << /ActualText (AB) >> BDC (xy) Tj EMC", font, "", &[descriptor, stream("", data)]);
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
assert_eq!(plain(&report), "AB");
assert!(!report.warnings.iter().any(|w| w.contains("differ from the installed font")), "{:?}", report.warnings);
}
#[test]
fn actual_text_nested_mask_interpretation_does_not_consume_page_owner() {
let mask = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 600 800] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /Font << /F 3 0 R >> >>",
b"/Span << /ActualText (MASK) >> BDC BT /F 20 Tf 1 0 0 1 20 700 Tm (mask) Tj ET EMC 1 g 0 0 600 800 re f",
);
let bytes = custom_pdf(
"/Span << /ActualText (AB) >> BDC /Mask gs (xy) Tj EMC",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
"/ExtGState << /Mask << /SMask << /S /Luminosity /G 6 0 R >> >> >>",
&[mask],
);
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
assert_eq!(plain(&report), "AB");
}
#[test]
fn actual_text_empty_span_keeps_following_position_without_slots() {
for (content, baseline) in [
("(P) Tj /Span << /ActualText () >> BDC (xy) Tj EMC (CD) Tj", "(PxyCD) Tj"),
("/Span << /ActualText () >> BDC (xy) Tj EMC (CD) Tj", "(xyCD) Tj"),
] {
let report = open_positioned(content);
let ordinary = open_positioned(baseline);
assert_eq!(plain(&report), if content.starts_with("(P)") { "PCD" } else { "CD" });
for c in ['C', 'D'] {
assert!((character_origin(&report, c) - character_origin(&ordinary, c)).hypot() < 0.05, "{content}: {c}");
}
}
}
#[test]
fn actual_text_waits_for_a_placeable_glyph() {
let report = open("/Span << /ActualText (AB) >> BDC -1 0 0 1 20 700 Tm (x) Tj 1 0 0 1 20 660 Tm (y) Tj EMC (C) Tj");
assert_eq!(plain(&report), "ABC");
}
#[test]
fn actual_text_stroke_does_not_restyle_ordinary_prefix() {
let report = open("(P) Tj 2 Tr /Span << /ActualText (AB) >> BDC (xyzw) Tj EMC");
assert_eq!(plain(&report), "PAB");
for text in texts(&report) {
for run in text.runs {
assert_eq!(run.style.has_stroke(), !run.text.contains('P'), "{:?}", run.text);
}
}
}
#[test]
fn actual_text_vertical_column_keeps_replacement_once() {
let font = b"<< /Type /Font /Subtype /Type0 /BaseFont /Vertical /Encoding /Identity-V /DescendantFonts [6 0 R] /ToUnicode 8 0 R >>";
let descendant = b"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /Vertical /CIDSystemInfo << /Registry (Unknown) /Ordering (Identity) /Supplement 0 >> /FontDescriptor 7 0 R /DW 500 /DW2 [880 -1000] >>".to_vec();
let descriptor = b"<< /Type /FontDescriptor /FontName /Vertical /Flags 4 /FontBBox [0 0 1000 1000] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >>".to_vec();
let map = stream("", b"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def 1 begincodespacerange <0000> <FFFF> endcodespacerange 3 beginbfchar <0001> <0078> <0002> <0079> <0003> <0043> endbfchar endcmap end end");
let extra = [descendant, descriptor, map];
for (replacement, expected) in [("AB", "ABC"), ("", "C")] {
let content = format!("/Span << /ActualText ({replacement}) >> BDC <00010002> Tj EMC <0003> Tj");
let bytes = custom_pdf(&content, font, "", &extra);
let report = import_with_report(&bytes, &ImportOptions::default()).unwrap();
assert_eq!(plain(&report), expected);
assert!(texts(&report).iter().all(|text| text.vertical));
}
}
+135
View File
@@ -297,3 +297,138 @@ fn an_uncolored_pattern_drawing_an_image_mask_keeps_each_color() {
}
assert_eq!(colors, [["#ff0000"], ["#0000ff"]]);
}
const SPOT_RED: &str = "[/Separation /SpotRed /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]";
/// A page drawing a `w` × 1 image in color space `space` (`bpc` bits a sample, more dictionary
/// entries `more`, samples `hex`), then the `extra` objects after it.
fn separation_page(space: &str, w: u32, bpc: u8, more: &str, hex: &str, extra: &[&str]) -> Document {
separation_report(space, w, bpc, more, hex, extra, "q 80 0 0 10 10 50 cm /Im1 Do Q").document
}
/// [`separation_page`] drawn with `content`, with the import's report. `more` may name filters
/// after ASCIIHex (`/Filter [/ASCIIHexDecode …]`).
fn separation_report(space: &str, w: u32, bpc: u8, more: &str, hex: &str, extra: &[&str], content: &str) -> ImportReport {
let filter = if more.contains("/Filter") { "" } else { "/Filter /ASCIIHexDecode" };
let image = format!(
"<< /Type /XObject /Subtype /Image /Width {w} /Height 1 /ColorSpace {space} /BitsPerComponent {bpc} {more} {filter} /Length {} >>\nstream\n{hex}>\nendstream",
hex.len() + 1
);
let resources = format!("/XObject << /Im1 {} 0 R >>", first_extra(1));
let mut objects = vec![image.as_str()];
objects.extend_from_slice(extra);
let pdf = pdf_with(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &objects, None);
import_with_report(&pdf, &ImportOptions::default()).unwrap()
}
/// An image in a spot ink's Separation space opens as a grayscale image of its tints, colorized by
/// the ink's spot swatch at 100 %.
#[test]
fn a_separation_image_opens_colorized_by_its_ink() {
let d = separation_page(SPOT_RED, 4, 8, "", "0055AAFF", &[]);
let images = imported_images(&d);
assert_eq!(images.len(), 1);
let (paint, gray) = &images[0];
assert_eq!(gray, &[255, 170, 85, 0], "the tints 0, 1/3, 2/3 and 1");
let Some(Paint::Solid { swatch, tint, .. }) = paint else { panic!("colorized: {paint:?}") };
assert_eq!((swatch.as_deref(), *tint), (Some("SpotRed"), 1.0));
assert!(d.swatch("SpotRed").is_some_and(|s| s.spot), "a spot swatch");
}
/// Samples of 1, 4 and 16 bits are unpacked, and the Decode array applied.
#[test]
fn a_separation_image_reads_its_samples_and_decode_array() {
for (bpc, w, more, hex, want) in [
(1, 8, "/Decode [1 0]", "C3", vec![255, 255, 0, 0, 0, 0, 255, 255]),
(4, 3, "", "0F80", vec![255, 0, 119]),
(16, 2, "", "0000FFFF", vec![255, 0]),
] {
let d = separation_page(SPOT_RED, w, bpc, more, hex, &[]);
let images = imported_images(&d);
assert_eq!(images.len(), 1, "{bpc} bits");
assert_eq!(images[0].1, want, "{bpc} bits");
assert!(images[0].0.is_some(), "{bpc} bits: colorized");
}
}
/// A Separation of a process plate colorizes in that plate's CMYK color; the ink `None`, which
/// paints nothing, is left to the interpreter.
#[test]
fn a_separation_image_of_a_process_plate_or_of_no_ink() {
let black = "[/Separation /Black /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>]";
let d = separation_page(black, 2, 8, "", "00FF", &[]);
let images = imported_images(&d);
assert_eq!(images.len(), 1);
assert_eq!(images[0].0, Some(Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0))));
assert!(d.swatches.iter().all(|s| !s.spot), "no spot swatch for a process plate");
let none = "[/Separation /None /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]";
let d = separation_page(none, 2, 8, "", "00FF", &[]);
let mut colorized = vec![];
for l in &d.layers {
l.walk(&mut |n| {
if matches!(n.kind, NodeKind::Image(_)) {
colorized.push(n.extras().colorize.is_some());
}
});
}
assert!(colorized.iter().all(|c| !c), "{colorized:?}");
}
/// A Separation image's soft mask becomes its opacity mask.
#[test]
fn a_separation_image_keeps_its_soft_mask() {
let smask = "<< /Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 9 >>\nstream\nFF00FF00>\nendstream";
let d = separation_page(SPOT_RED, 4, 8, &format!("/SMask {} 0 R", first_extra(1) + 1), "0055AAFF", &[smask]);
let mut found = vec![];
for l in &d.layers {
l.walk(&mut |n| {
if matches!(n.kind, NodeKind::Image(_)) && n.extras().colorize.is_some() {
found.push(n.extras().mask.is_some());
}
});
}
assert_eq!(found, [true]);
}
/// The same Separation image drawn twice is colorized twice, over the same pixels.
#[test]
fn a_separation_image_drawn_twice_is_colorized_twice() {
let d = separation_report(SPOT_RED, 4, 8, "", "0055AAFF", &[], "q 80 0 0 10 10 20 cm /Im1 Do Q q 80 0 0 10 10 60 cm /Im1 Do Q").document;
let images = imported_images(&d);
assert_eq!(images.len(), 2);
assert_eq!(images[0], images[1]);
assert!(images[0].0.is_some(), "colorized");
}
/// A one-component JPEG in a Separation space is read through its decoder.
#[test]
fn a_separation_jpeg_opens_colorized_by_its_ink() {
let mut jpeg = vec![];
jpeg_encoder::Encoder::new(&mut jpeg, 100).encode(&[0, 0, 255, 255, 0, 0, 255, 255], 8, 1, jpeg_encoder::ColorType::Luma).unwrap();
let hex: String = jpeg.iter().map(|b| format!("{b:02X}")).collect();
let d = separation_page(SPOT_RED, 8, 8, "/Filter [/ASCIIHexDecode /DCTDecode]", &hex, &[]);
let images = imported_images(&d);
assert_eq!(images.len(), 1);
assert!(images[0].0.is_some(), "colorized");
// Sample 0 lays no ink (white), 255 lays it all (black); JPEG blurs the edges a little.
let gray = &images[0].1;
assert!(gray[0] > 200 && gray[2] < 55 && gray[4] > 200 && gray[6] < 55, "{gray:?}");
}
/// A Separation image whose tints can't be read (its data cut short) opens in RGB each time it is
/// drawn, with one warning and no swatch for its ink.
#[test]
fn a_separation_image_that_cant_be_read_stays_rgb() {
let r = separation_report(SPOT_RED, 4, 8, "", "0055", &[], "q 80 0 0 10 10 20 cm /Im1 Do Q q 80 0 0 10 10 60 cm /Im1 Do Q");
let mut colorized = vec![];
for l in &r.document.layers {
l.walk(&mut |n| {
if matches!(n.kind, NodeKind::Image(_)) {
colorized.push(n.extras().colorize.is_some());
}
});
}
assert_eq!(colorized, [false, false]);
assert!(r.document.swatches.iter().all(|s| !s.spot), "no swatch for an ink nothing uses");
assert_eq!(r.warnings.iter().filter(|w| w.contains("spot ink")).count(), 1, "{:?}", r.warnings);
}
+33 -1
View File
@@ -24,9 +24,40 @@ pub(super) const SPEC: DialogSpec = DialogSpec {
preview: true,
// Load Texture… adds the texture it reads.
passes: |d, field| field == vectorcraft_effects::pixel::TEXTURE_IMAGE && matches!(d.str("__effect").as_str(), GLASS | TEXTURIZER),
// OK could only fail the same way: off, beside the reason the dialog shows (#6140).
ok_enabled: Some(|_, d| !(vectorcraft_effects::is_three_d(&d.str("__effect")) && d.fields.contains_key(form::REFUSED))),
..DialogSpec::FORM
};
/// The field holding the values a 3D effect's dialog last tried with Preview off.
const TRIED: &str = "__tried";
/// A 3D effect refuses objects it can't build from and values it can't draw, in the dialog as OK
/// would: shows why under Preview (#6140). With Preview off no preview has tried `params`, so they
/// are tried here, when they change, and rolled back at once.
fn three_d_refusal(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, cmd: &str, params: Value) {
if d.bool("preview") {
d.fields.remove(TRIED);
} else if !app.session.in_interaction() && d.fields.get(TRIED) != Some(&params) {
// Waits for another interaction to end: trying inside it would replace its preview and
// cancel it. A failure to begin leaves no interaction: the preview below then reports it.
let _ = app.session.begin_interaction(&d.str("__label"));
match app.session.preview(cmd, &params) {
Ok(_) => {
d.fields.remove(form::REFUSED);
}
Err(e) => form::note_refusal(d, &e),
}
// Only a try: without an interaction there is nothing to roll back.
let _ = app.session.cancel_interaction();
d.fields.insert(TRIED.into(), params);
}
if let Some(why) = d.fields.get(form::REFUSED).and_then(Value::as_str) {
ui.add_space(6.0);
ui.add(egui::Label::new(egui::RichText::new(why).color(Tokens::get(ui.ctx()).warning)).wrap());
}
}
/// The effect's name (`__label`, English) in the UI language: the catalog may carry it with or
/// without its trailing ellipsis. A plug-in effect's name is shown as it is.
fn heading_label(d: &Dialog) -> String {
@@ -170,7 +201,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if vectorcraft_effects::is_three_d(&id) {
super::revolve::body(app, ui, d);
let (cmd, params) = command(d);
form::preview(app, ui, d, &d.str("__label"), cmd, params);
form::preview(app, ui, d, &d.str("__label"), cmd, params.clone());
three_d_refusal(app, ui, d, cmd, params);
return false;
}
// Plug-in effects get fields from their parameter schema; colour adjustments their sliders.
+21 -2
View File
@@ -966,6 +966,19 @@ fn rail_ends_w(ui: &mut egui::Ui, width: f32, (left, right): (&str, &str)) {
/// The field where [`preview`] keeps the parameters it last previewed.
pub(super) const PREVIEWED: &str = "__previewed";
/// The field holding why the dialog's command last refused its values, for the dialog to show.
pub(super) const REFUSED: &str = "__refused";
/// Keeps why the command refused the dialog's values in [`REFUSED`]: a refused value's message is
/// written for the user, so it goes without the command's id that the error's text puts before it.
pub(super) fn note_refusal(d: &mut Dialog, e: &vectorcraft_engine::EngineError) {
let why = match e {
vectorcraft_engine::EngineError::BadParams { msg, .. } => msg.clone(),
other => other.to_string(),
};
d.fields.insert(REFUSED.into(), json!(why));
}
/// The Preview checkbox of a dialog that previews `cmd` on the canvas: while it is on, `params` run
/// again on the interaction's snapshot whenever they differ from the last preview (whether a widget
/// or `ui.dialog.set` changed them); turning it off rolls back.
@@ -1022,8 +1035,14 @@ pub(super) fn preview_now(app: &mut crate::VectorcraftApp, d: &mut Dialog, label
if d.fields.get(PREVIEWED) != Some(&params) || !app.session.in_interaction() {
let _ = app.session.begin_interaction(label);
match app.session.preview(cmd, &params) {
Ok(r) => result = Some(r),
Err(e) => app.status(e.to_string()),
Ok(r) => {
d.fields.remove(REFUSED);
result = Some(r);
}
Err(e) => {
note_refusal(d, &e);
app.status(e.to_string());
}
}
d.fields.insert(PREVIEWED.into(), params);
}
@@ -2,6 +2,7 @@
use super::*;
use crate::{dialogs, theme};
use egui::{Event, Modifiers, PointerButton};
use serde_json::Value;
use vectorcraft_effects::revolve_options;
use vectorcraft_engine::Session;
@@ -284,3 +285,94 @@ fn selected_light_gizmo_edits_only_that_light_and_escape_restores_the_list() {
h.release(p + vec2(80.0, 0.0), Modifiers::NONE);
assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields["lights"], before);
}
impl Harness {
/// A fresh document where `build` runs and Extrude & Bevel's dialog opens, its Preview `on`.
fn extrude_dialog(build: &[(&str, Value)], on: bool) -> Self {
let mut h = Self::new();
dialogs::cancel(&mut h.app);
h.app.run("file.new", json!({"width":300,"height":300})).unwrap();
for (id, p) in build {
h.app.run(id, p.clone()).unwrap();
}
h.app.run("effect.dialog", json!({"effect": vectorcraft_effects::EXTRUDE})).unwrap();
h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(on));
h.settle();
h
}
fn ok_is_on(&self) -> bool {
let d = self.app.ui.dialog.as_ref().unwrap();
dialogs::spec(&d.kind).ok_enabled.is_none_or(|f| f(&self.app, d))
}
/// Does the next frame of the dialog draw a text holding `text`?
fn shows(&mut self, text: &str) -> bool {
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))), ..Default::default() };
let mut out = self.ctx.run_ui(input, |ui| dialogs::show(&mut self.app, ui.ctx()));
out.textures_delta.clear();
out.shapes.iter().any(|c| matches!(&c.shape, egui::Shape::Text(t) if t.galley.job.text.contains(text)))
}
}
fn clipping_group() -> Vec<(&'static str, Value)> {
vec![
("shape.ellipse", json!({"x":20,"y":20,"width":100,"height":100})),
("shape.rectangle", json!({"x":60,"y":60,"width":100,"height":100})),
("select.all", json!({})),
("object.clippingMask.make", json!({})),
]
}
/// #6140: on an object a 3D effect can't take, the dialog says why in its own words, and OK, which
/// could only fail, is off, where it used to look as if OK did nothing.
#[test]
fn a_refused_3d_effect_says_why_and_turns_ok_off() {
let mut h = Harness::extrude_dialog(&clipping_group(), true);
assert!(!h.ok_is_on());
assert!(h.shows("requires a path"));
assert!(!h.shows("effect.apply"), "the reason is written without the command's id");
let enter = Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE };
h.frame(vec![enter], Modifiers::NONE);
assert!(h.app.ui.dialog.is_some());
}
/// With Preview off from the start, nothing has tried the effect: the dialog still finds out,
/// and trying it leaves no preview open.
#[test]
fn a_refused_3d_effect_is_found_with_preview_off() {
let mut h = Harness::extrude_dialog(&clipping_group(), false);
assert!(!h.ok_is_on());
assert!(h.shows("requires a path"));
assert!(!h.app.session.in_interaction());
}
/// With Preview off, trying the values waits for another interaction to end: trying them during
/// one would replace its preview and cancel it.
#[test]
fn a_3d_effect_with_preview_off_leaves_another_interaction_alone() {
let mut h = Harness::extrude_dialog(&clipping_group(), false);
h.app.session.begin_interaction("Drag").unwrap();
let d = h.app.ui.dialog.as_mut().unwrap();
d.fields.remove("__tried");
d.fields.remove(dialogs::form::REFUSED);
h.settle();
assert!(h.app.session.in_interaction(), "the other interaction goes on");
assert!(h.ok_is_on(), "nothing has tried the values yet");
h.app.session.cancel_interaction().unwrap();
assert!(h.shows("requires a path"));
assert!(!h.ok_is_on());
assert!(!h.app.session.in_interaction());
}
/// An object the effect can take keeps OK on, with Preview on or off, and shows no reason.
#[test]
fn an_accepted_3d_effect_keeps_ok_on() {
let build = [("shape.rectangle", json!({"x":60,"y":60,"width":100,"height":100}))];
for on in [true, false] {
let mut h = Harness::extrude_dialog(&build, on);
assert!(h.ok_is_on(), "Preview {on}");
assert!(!h.shows("requires a path"), "Preview {on}");
assert_eq!(h.app.session.in_interaction(), on);
}
}
+8 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -280,7 +280,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`); documents with a lot of path data are written as compact JSON, about a fifth of the size (#1492); a save falls back to a plain fsync and to writing elsewhere and copying in when the folder refuses the usual steps (#1625, #1649, part of #1620; not confirmed on a real network share); the size limits on embedded data also apply to files read from memory (#1783), and saving over a private file on Unix leaves no readable temporary copy beside it (#1778); a dynamic symbol's type (`dynamic` on the symbol) and an instance's appearance changes to its art (`symbol_overrides`, each path at most 64 deep, counted against the object budget) are written only when set, so documents without them save as before (#4938) |
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. |
| PDF | open, place, save | yes (authored ActualText in editable import, #6165; layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160); an image mask (`/ImageMask true`) painted in a solid color opens as a grayscale image colorized by that color, a spot color keeping its swatch and tint, so Fill and Recolor Artwork reach it, and the same mask painted again in another color keeps that color (#6313); an image in a Separation space opens as a grayscale image of its tints colorized by its ink at 100 % (a spot swatch, a process plate or Registration), its soft mask as its opacity mask (#6314) PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. |
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) |
@@ -310,6 +310,10 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
## PDF editable imports
- [x] Explicit `/ActualText` replaces covered glyph text once, including empty strings and nested spans (outer owner wins), while later glyphs retain pen geometry. Synthetic regressions in `crates/pdf/src/tests/actual_text.rs`; no added fonts/assets. UTF-16BE, BOM UTF-8 and PDFDocEncoding reuse the reader's text-string decoder.
- [x] Authored ordering is protected per stretch; ordinary stretches still reconstruct visual-order text. Outlines mode is unchanged.
- [ ] Exact redistribution of one replacement over several lines/styles, and general mixed-direction paragraph layout. Replacement uses the first placed glyph's line/style. Tags deeper than 128 and replacements over 64 KiB encoded (at most 192 KiB decoded) fall back to glyph mappings with a warning; an outer accepted owner survives deeper tags.
- [x] Place from bytes or unlinked paths, and PDF clipboard paste, keep the selected page's live text. Synthetic two-page regressions in `crates/engine/src/cmd/fileio/tests_pdffidelity.rs` cover both sources, page selection and explicit outlines. The single-page importer honors the same text choice as Open; existing PDF fallback rules are unchanged.
## Corpus download integrity
@@ -320,7 +324,9 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) |
| 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) |
| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. |
| 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. |
| 2026-11-07 | minor | EPS and `.ai` editing data: when the page's type is matched with the text objects, a glyph the page's font encoding names with a private-use character (a ligature such as `uniE028`, which says nothing of the letters it stands for) splits the page's word there, and the pieces are matched as words. A ligature in "Quality" no longer reads "Qualy" against the text object: four users' Illustrator 30 EPS files (used locally, not committed) lose the false "some of the page's type has no text object" note, their renders unchanged |
| 2026-11-07 | minor | EPS import: a CID-keyed font over a predefined Unicode CMap the file doesn't define (Adobe's public CMap resources named `Uni…-UCS2`, `-UTF8`, `-UTF16` or `-UTF32`, `-H` or `-V`: [cmap-resources](https://github.com/adobe-type-tools/cmap-resources)), found by its `CIDFont-CMap` name (`/Ryumin-Light-UniJIS-UCS2-H findfont`, the usual name of a CID-keyed font: its CIDFont's and CMap's names joined by a hyphen) or made with `composefont`, sets its codes' characters as live type in the font of its CIDFont's name, and `stringwidth` measures them. Such type was read as Latin-1 codes, or stopped the program at `composefont`. Vertical CMaps are set horizontally, and the show operators' extra spacing isn't kept; CIDFonts over other CMaps the file doesn't carry still draw nothing, with a warning. |
+5 -2
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -197,6 +197,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
## G9 Illustrator `.ai` and EPS files from real users
- **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate.
- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9.
- **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged.
- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged.
@@ -313,7 +315,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi
## G11 Correctness bugs in core tools
- **Export-only improvement (#6309):** PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. Broader G11 scope, scores and estimates unchanged.
- **Export-only improvement (#6309):** PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. Broader G11 scope, scores and estimates unchanged.
- **Done, deletion only (#3380):** deleting anchors or segments that disconnect an ordinary contour creates independent selected path objects, preserving appearance, baked transforms, sibling order and one-step Undo/Redo. Existing multi-subpath paths and compound members stay intact. A second Delete removes all selected survivors. Bounds-fitted gradients keep their original placement on the new objects; already placed gradients stay unchanged. Segment endpoint highlighting remains open; broader G11 scope, scores and estimates are unchanged. Evidence: `crates/engine/tests/it/delete_path_pieces.rs` and the Delete/Backspace frame regression in `crates/ui-egui/src/tests/removeanchors.rs`.
@@ -855,6 +857,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| 2026-11-09 | minor | G5: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); Missing trimmed; estimate 12–22 → 11–21 h. |
| 2026-11-09 | minor | G5: Direct Selection of a dynamic instance part takes opacity, blend, gradient and pattern fill/stroke; Art brush Overlap and Pattern auto corners documented Done; estimate 15–25 → 12–22 h. |
| 2026-11-09 | minor | G5: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out documented as Done (already in code); Missing trimmed; estimate unchanged. |
| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. |
| 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. |
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
| 2026-11-09 | minor | G4 type: the custom dictionary (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, kept in a file beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; the Check Spelling and Edit Custom Dictionary dialogs and Auto Spell Check remain; scores and estimates unchanged. |
+3 -2
View File
@@ -1,6 +1,6 @@
# Roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
## Feature submission evidence
@@ -133,7 +133,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l
| M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default | 4–8 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 |
@@ -191,6 +191,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
| Date | Change | Summary |
|---|---|---|
| 2026-11-09 | minor | M7: Auto Spell Check (#4936); the dialogs and the right-click Spelling menu remain; scores and estimates unchanged. |
| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. |
| 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. |
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
| 2026-11-09 | minor | M7: the custom dictionary commands and Ignore All (#4936); the dialogs and Auto Spell Check remain; scores and estimates unchanged. |
+9 -5
View File
@@ -1,6 +1,6 @@
# Parity with Adobe Illustrator
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Line Arrow arrowhead (#5421); scores and estimates unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Line Arrow arrowhead (#5421); scores and estimates unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
## Feature submission evidence
@@ -227,9 +227,9 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; large images trace with a warning; Auto Color keeps rare distinct colours) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them |
| Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged |
| View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) |
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); import ActualText (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. |
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); import ActualText (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. |
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); import ActualText (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. |
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box (`tests_native::aligned_type_lies_as_far_from_its_path_as_the_file_lays_it_out`), and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. |
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. |
| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified PDF marked-text batching covered by M4 evidence (#6309); cross-line/style ActualText layout (#6165) and styled-cluster limits remain.| 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. PDF ActualText preserved in editable import (#6165; bounded synthetic coverage below); score/hours unchanged. |
| View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) |
| View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) |
| Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); Control bar snapping quick access includes Snap to Glyph and Alignment Guides (#6164); new ruler-guide Alt orientation implemented; global/artboard ruler modes (`view.globalRulers`) and video rulers (View › Rulers › Show Video Rulers, #2849) done; status-bar X/Y uses the active ruler origin and General units; score unchanged |
@@ -542,6 +542,9 @@ since 2026-10-01.
## Evidence: what ships today, by area
- **PDF authored replacement text (#6165):** `crates/pdf/src/tests/actual_text.rs` covers replacement once with suffix placement, PDF text-string decoding, empty and nested owners, sibling spans, page reset, multiple text-show operations, style/line changes, fill-and-stroke, reader windows and bounded input. Authored logical-order stretches are protected while adjacent ordinary text still undergoes reconstruction. The replacement takes its first placed glyph's style and line; its characters are not redistributed over later lines/styles. Exact mixed-direction paragraph layout is not claimed. No export changes; scores and hours stay at 75% / 10–20 h.
- **Selection — disconnected path deletion (#3380, deletion only):** Clear/Delete and Cut turn surviving disconnected pieces of an ordinary contour into independent selected sibling objects. The first keeps its ID; all keep appearance, baked transforms and stacking order. `deletion_split_preserves_gradient_placement` compares rendered survivors with the original paint space and checks linear/radial fill/stroke vectors, already placed gradients and atomic Undo/Redo; connected remainders keep automatic fitting. Existing multi-subpath paths and explicit compound members retain their semantics. Original synthetic regressions in `crates/engine/tests/it/delete_path_pieces.rs` cover styled/transformed rectangles, cubic handles, compound preservation and atomic Undo/Redo; `crates/ui-egui/src/tests/removeanchors.rs` covers real-frame Delete/Backspace and a second Delete removing all survivors. Segment endpoint highlighting remains open. Scores and hours unchanged.
- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged.
@@ -1307,7 +1310,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR=<craft-fonts checkout> cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`).
A font family removed while the app runs leaves the font menus when the app scans the font folders again (when its window comes to the front after a font folder changed, or Refresh Font List), also when type had used it: that type then draws in the fallback font and Find Font lists the font as missing, until the font is installed again and the folders are scanned.
- **Files (M4):**
- PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import still needs ActualText support (#6165); separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence.
- PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence.
- PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source.
PDF export measures each member of a plain group or layer once to leave out what is off the page: the group or layer is measured through its members (`Node::visual_of_members`), and each member uses that box when it is written. The elements of a knockout group are measured twice, once for the group's box and once to choose the elements on the page (`pdf/src/tests/measuring.rs` counts the measures). A local byte comparison with the previous build found identical output on one open path of 200,000 anchors, 20,000 rectangles in 200 groups of 100, 5,000 point type objects and a document of plain, see-through, clipping and knockout groups. The walk that writes link annotations measures only an object with a URL that opens and a clipping group (`links.rs`), and its output on a document of linked objects in plain and clipping groups is the same byte for byte as when it measured every object.
Invisible text in a PDF (rendering mode 3, which carries a scanned page's recognized text over the scan) opens as type with no fill or stroke, a run of its own beside visible text on the same line; text that can't come in as type (opened as outlines, Type 3 fonts) is left out with a warning. PDF export, and printing, write characters with no fill or stroke as real text in rendering mode 3, so they draw nothing but stay selectable and searchable, also in PDF/X-1a and PDF/A-2b (#2675, `pdf/src/tests/import_fidelity.rs`, `pdf/src/tests/realtext.rs`).
@@ -1909,6 +1912,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
| 2026-11-09 | minor | Bundled content: built-in UI Frames symbol library (Panel, Button, Bar with default 9-slice); symbol libraries evidence updated; ~30% → ~32%; more built-in symbol libraries remain. |
| 2026-11-09 | minor | Symbols: on-canvas dynamic part appearance takes gradient/pattern fill and stroke and opacity/blend; evidence updated; area ~87% → ~88%; strokes remaining no longer lists Overlap/auto corners. |
| 2026-11-09 | minor | Strokes/brushes: Art brush Overlap and Pattern brush auto corners removed from remaining (evidence already covered them); symbols remaining clarified for dynamic on-canvas beyond solid fill/stroke; scores and hours unchanged. |
| 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. |
| 2026-11-09 | minor | PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged. Decoded stream and placement regressions; reader ActualText (#6165), styled clusters and external viewer validation remain separate. |
| 2026-11-09 | minor | Interim macOS canvas cursor delivery (#2292): existing artwork in aligned 1×/2× AppKit representations, visible system fallback and host-owned restoration. Headless routing, bitmap and ownership coverage; native timing, menus, focus/handback, mixed displays, Spotlight and full-screen Spaces remain unverified. Scores and estimates unchanged. |
| 2026-11-09 | minor | Type advanced: the custom dictionary commands (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, saved beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; evidence added; the dialogs and Auto Spell Check remain; scores and hours unchanged. |