Fix #1388: PDF import keeps overprint
This commit is contained in:
parent
5e4fc4e3af
commit
bfb173e967
|
|
@ -713,7 +713,7 @@ fn rich_resources_pdf(content: &str, config: &str) -> Vec<u8> {
|
|||
];
|
||||
let (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5);
|
||||
let resources = format!(
|
||||
"/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
|
||||
"/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /OP true /OPM 1 /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /OP true /op false /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> /GS2 << /OP 7 /op (x) /OPM -3 >> /GS3 << /op true >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
|
||||
);
|
||||
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R 99 0 R] /D << {} >> >> ", config.replace("{B}", &format!("{b} 0 R")));
|
||||
let extra: Vec<&str> = extra.iter().map(String::as_str).collect();
|
||||
|
|
@ -734,6 +734,9 @@ fn arb_rich_op() -> impl Strategy<Value = String> {
|
|||
"/X1 Do",
|
||||
"/GS0 gs",
|
||||
"/GS1 gs",
|
||||
// Overprint (#1388): set, malformed, and for fills alone.
|
||||
"/GS2 gs",
|
||||
"/GS3 gs",
|
||||
"/CS0 cs 1 0 0 /P0 scn",
|
||||
"/Pattern cs /P0 scn",
|
||||
"/Sh0 sh",
|
||||
|
|
|
|||
|
|
@ -21,13 +21,18 @@ use vectorcraft_geom::{FillRule, PathData};
|
|||
|
||||
use crate::import_color::{Colors, Native};
|
||||
use crate::import_mask::{MaskSpec, contains, is_rectangle, luminance, mask_spec, white_cover};
|
||||
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, TextOverprint, all_on, hides_forms, scan_page, tag_key};
|
||||
use crate::import_shading::{clipped, extend_clip, fold_stop_opacity, gradient_shape, mesh_shading, shading_gradient};
|
||||
use crate::import_text::{Families, LineFacts, Look, Placement, TextLine, Upright};
|
||||
use crate::{CropTo, ImportOptions, ImportReport, PdfError, TextAs};
|
||||
|
||||
/// The name of the paths text imports as.
|
||||
const TEXT_OUTLINES: &str = "<Text Outlines>";
|
||||
/// A page whose overprint (ISO 32000-1 §8.6.7) is left out rather than given to the wrong objects.
|
||||
const OVERPRINT_UNREAD: &str = "the overprint of a page's objects couldn't be read: they knock out";
|
||||
/// The document overprints as overprint mode 1 does.
|
||||
const OVERPRINT_ZERO_MODE: &str =
|
||||
"overprint in the mode where a process colour's zero tints knock out (/OPM 0) is imported as overprint, where they don't";
|
||||
/// The deepest soft masks, patterns and type 3 glyphs are read within each other.
|
||||
const MAX_NESTED: u32 = 8;
|
||||
/// A tiling pattern read this many times with the same art is taken to always draw it.
|
||||
|
|
@ -666,6 +671,7 @@ struct GlyphRun {
|
|||
opacity: f32,
|
||||
stroke: Option<StrokeProps>,
|
||||
scale: f64,
|
||||
overprint: bool,
|
||||
}
|
||||
|
||||
/// How often a tiling pattern was read, and the art (as its paints) each reading drew with the
|
||||
|
|
@ -734,6 +740,8 @@ struct Builder<'p> {
|
|||
scan: Scan,
|
||||
tag_at: usize,
|
||||
group_at: usize,
|
||||
/// The fills and strokes of paths drawn so far ([`Scan::paints`]).
|
||||
paint_at: usize,
|
||||
aligned: bool,
|
||||
/// Whether optional content groups become layers.
|
||||
route: bool,
|
||||
|
|
@ -880,6 +888,7 @@ impl<'p> Builder<'p> {
|
|||
scan: Scan::default(),
|
||||
tag_at: 0,
|
||||
group_at: 0,
|
||||
paint_at: 0,
|
||||
aligned: true,
|
||||
route,
|
||||
unsure: false,
|
||||
|
|
@ -930,12 +939,49 @@ impl<'p> Builder<'p> {
|
|||
self.masked.clear();
|
||||
self.marked.clear();
|
||||
self.pending = false;
|
||||
(self.scan, self.tag_at, self.group_at, self.aligned) = (scan, 0, 0, true);
|
||||
(self.scan, self.tag_at, self.group_at, self.aligned, self.paint_at) = (scan, 0, 0, true, 0);
|
||||
if self.scan.overprints && self.scan.cut {
|
||||
self.scan.overprints = false;
|
||||
self.warn(OVERPRINT_UNREAD);
|
||||
}
|
||||
if self.scan.overprints && self.scan.zero_mode {
|
||||
self.warn(OVERPRINT_ZERO_MODE);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the path the interpreter draws now (a fill or a stroke, each counted) overprints.
|
||||
fn path_overprint(&mut self) -> bool {
|
||||
if self.nested > 0 || !self.scan.overprints {
|
||||
return false;
|
||||
}
|
||||
self.paint_at += 1;
|
||||
self.scan.paints.get(self.paint_at - 1).copied().unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether the glyph the interpreter draws now (its fill, or its `stroke`) overprints.
|
||||
fn glyph_overprint(&mut self, stroke: bool) -> bool {
|
||||
if self.nested > 0 || !self.scan.overprints {
|
||||
return false;
|
||||
}
|
||||
match self.scan.texts.get(self.paint_at) {
|
||||
Some(TextOverprint::All { fill, stroke: s }) => *if stroke { s } else { fill },
|
||||
Some(TextOverprint::Mixed) => {
|
||||
self.warn("some text is set to overprint in a way that can't be read: it knocks out");
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's art, split by optional content group (in the order they first paint).
|
||||
fn end_page(&mut self) -> Vec<(Option<usize>, Vec<Arc<Node>>)> {
|
||||
let children = self.close_frames();
|
||||
let mut children = self.close_frames();
|
||||
// The interpreter drew another number of paths than the file was read to paint: which
|
||||
// of them overprint can't be trusted.
|
||||
if self.scan.overprints && self.paint_at != self.scan.paints.len() {
|
||||
children.iter_mut().for_each(vectorcraft_doc::overprint::clear_overprints);
|
||||
self.warn(OVERPRINT_UNREAD);
|
||||
}
|
||||
let groups = std::mem::take(&mut self.root_groups);
|
||||
let mut parts: Vec<(Option<usize>, Vec<Arc<Node>>)> = vec![];
|
||||
// Thousands of groups: find each one's part by index.
|
||||
|
|
@ -1137,10 +1183,14 @@ impl<'p> Builder<'p> {
|
|||
match &run.stroke {
|
||||
None => {
|
||||
let mut f = FillLayer::new(run.paint);
|
||||
f.opacity = run.opacity;
|
||||
(f.opacity, f.overprint) = (run.opacity, run.overprint);
|
||||
ap.items.push(AppearanceItem::Fill(f));
|
||||
}
|
||||
Some(p) => ap.items.push(AppearanceItem::Stroke(stroke_layer(run.paint, run.opacity, p, run.scale))),
|
||||
Some(p) => {
|
||||
let mut st = stroke_layer(run.paint, run.opacity, p, run.scale);
|
||||
st.overprint = run.overprint;
|
||||
ap.items.push(AppearanceItem::Stroke(st));
|
||||
}
|
||||
}
|
||||
let id = self.id();
|
||||
let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap);
|
||||
|
|
@ -1398,6 +1448,7 @@ impl<'p> Builder<'p> {
|
|||
scale: f64,
|
||||
paint: &hayro_interpret::Paint<'_>,
|
||||
stroke: Option<&StrokeProps>,
|
||||
overprint: bool,
|
||||
) -> bool {
|
||||
if self.text_as != TextAs::Text || self.nested > 0 {
|
||||
return false;
|
||||
|
|
@ -1489,6 +1540,8 @@ impl<'p> Builder<'p> {
|
|||
version,
|
||||
size: at.size,
|
||||
h_scale: at.h_scale,
|
||||
overprint_fill: stroke.is_none() && overprint,
|
||||
overprint_stroke: stroke.is_some() && overprint,
|
||||
fill: stroke.is_none().then_some(paint),
|
||||
stroke,
|
||||
};
|
||||
|
|
@ -1500,7 +1553,7 @@ impl<'p> Builder<'p> {
|
|||
// A stroke over the glyph just filled (fill and stroke rendering).
|
||||
if let (Some(s), Some(line)) = (&look.stroke, &mut self.text)
|
||||
&& look.fill.is_none()
|
||||
&& line.stroke_last(key, at, s.clone())
|
||||
&& line.stroke_last(key, at, s.clone(), overprint)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1566,6 +1619,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
self.flush_glyphs();
|
||||
self.flush_text();
|
||||
self.pending = false;
|
||||
let overprint = self.path_overprint();
|
||||
let mut bp = path.clone();
|
||||
bp.apply_affine(transform);
|
||||
if bp.elements().is_empty() {
|
||||
|
|
@ -1605,7 +1659,7 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
match draw_mode {
|
||||
PathDrawMode::Fill(rule) => {
|
||||
let mut f = FillLayer::new(paint);
|
||||
f.opacity = opacity;
|
||||
(f.opacity, f.overprint) = (opacity, overprint);
|
||||
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() });
|
||||
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
|
||||
*r = fill_rule(*rule);
|
||||
|
|
@ -1615,7 +1669,8 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
self.push_node(n);
|
||||
}
|
||||
PathDrawMode::Stroke(props) => {
|
||||
let st = stroke_layer(paint, opacity, props, mean_scale(transform));
|
||||
let mut st = stroke_layer(paint, opacity, props, mean_scale(transform));
|
||||
st.overprint = overprint;
|
||||
// Fill-then-stroke of the same path (the `B` operator, or a fill and a stroke of
|
||||
// the outline as Illustrator writes an object) becomes one object: a path with a
|
||||
// fill and a live stroke.
|
||||
|
|
@ -1699,7 +1754,8 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
match glyph {
|
||||
Glyph::Outline(o) => {
|
||||
let scale = mean_scale(transform);
|
||||
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref()) {
|
||||
let overprint = self.glyph_overprint(stroke.is_some());
|
||||
if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) {
|
||||
return;
|
||||
}
|
||||
self.flush_text();
|
||||
|
|
@ -1709,12 +1765,13 @@ impl<'a> Device<'a> for Builder<'_> {
|
|||
let same = self.glyphs.as_ref().is_some_and(|r| {
|
||||
r.paint == paint
|
||||
&& r.opacity == opacity
|
||||
&& r.overprint == overprint
|
||||
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width)
|
||||
&& (r.scale - scale).abs() < 1e-9
|
||||
});
|
||||
if !same {
|
||||
self.flush_glyphs();
|
||||
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale });
|
||||
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint });
|
||||
}
|
||||
if let Some(r) = &mut self.glyphs {
|
||||
r.path.extend(bp.iter());
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
//! What the interpreter doesn't tell the import, read from the file itself: the optional content
|
||||
//! groups (layers) with their states and nesting, which group each marked-content sequence belongs
|
||||
//! to, the isolate and knockout flags of transparency groups, and the names of the fonts.
|
||||
//! to, the isolate and knockout flags of transparency groups, the overprint parameters each path
|
||||
//! and each stretch of text is painted with, and the names of the fonts.
|
||||
//!
|
||||
//! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group
|
||||
//! without its flags, and skips the content of groups that are off. So each page's content is
|
||||
//! walked here in the interpreter's order (page content, then form XObjects as they are drawn,
|
||||
//! skipping what it skips): the k-th `BDC`/`BMC` it reports is the k-th one found here (its tag
|
||||
//! checks that), and the k-th form transparency group is the k-th group found here. To import
|
||||
//! checks that), the k-th form transparency group is the k-th group found here, and the k-th
|
||||
//! path it fills or strokes is the k-th one painted here (the page's count checks that). To import
|
||||
//! content that is off as hidden layers, [`all_on`] gives the file an update that turns every
|
||||
//! group on.
|
||||
|
||||
|
|
@ -219,6 +221,39 @@ pub(crate) struct Scan {
|
|||
pub groups: Vec<(bool, bool)>,
|
||||
/// The walk stopped at [`MAX_OPS`] or [`MAX_TAGS`]: the rest of the page isn't told apart.
|
||||
pub cut: bool,
|
||||
/// Each fill and each stroke of a path the interpreter draws (a fill-and-stroke operator is
|
||||
/// a fill, then a stroke; `sh` is a fill): whether it overprints.
|
||||
pub paints: Vec<bool>,
|
||||
/// How the text shown before each of [`Self::paints`] (since the one before it) overprints,
|
||||
/// and last the text after them all: one more entry than `paints`.
|
||||
pub texts: Vec<TextOverprint>,
|
||||
/// Something on the page overprints (when nothing does, `paints` and `texts` aren't used).
|
||||
pub overprints: bool,
|
||||
/// Something overprints with overprint mode 0, where a `DeviceCMYK` colour's zero components
|
||||
/// knock out; the document's overprint is the nonzero one (mode 1).
|
||||
pub zero_mode: bool,
|
||||
}
|
||||
|
||||
/// How the text shown between two painted paths overprints.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub(crate) enum TextOverprint {
|
||||
/// No text is shown there.
|
||||
#[default]
|
||||
None,
|
||||
/// All of it is shown with these parameters.
|
||||
All { fill: bool, stroke: bool },
|
||||
/// Text is shown with different parameters, which can't be told apart by glyph.
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// The overprint parameters of the graphics state (ISO 32000-1 §8.6.7; `OP`, `op` and `OPM` in
|
||||
/// Table 58).
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Overprint {
|
||||
stroke: bool,
|
||||
fill: bool,
|
||||
/// Overprint mode 1 (nonzero overprint).
|
||||
nonzero: bool,
|
||||
}
|
||||
|
||||
/// A marked-content tag as [`Scan::tags`] keeps it: its FNV-1a hash (the tags are only compared,
|
||||
|
|
@ -255,6 +290,11 @@ struct Walker<'w, 'p> {
|
|||
out: Scan,
|
||||
/// A form XObject hidden by its own optional content was met.
|
||||
hidden_form: bool,
|
||||
/// The saved graphics states' overprint parameters, the current one last, pushed and popped
|
||||
/// as the interpreter does its states.
|
||||
states: Vec<Overprint>,
|
||||
/// The text shown since the last painted path.
|
||||
text: TextOverprint,
|
||||
}
|
||||
|
||||
impl Walker<'_, '_> {
|
||||
|
|
@ -291,6 +331,49 @@ impl Walker<'_, '_> {
|
|||
self.begin(on);
|
||||
}
|
||||
|
||||
fn state(&self) -> Overprint {
|
||||
self.states.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save(&mut self) {
|
||||
self.states.push(self.state());
|
||||
}
|
||||
|
||||
/// The interpreter never pops its first state.
|
||||
fn restore(&mut self) {
|
||||
if self.states.len() > 1 {
|
||||
self.states.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn overprinting(&mut self, on: bool) {
|
||||
self.out.overprints |= on;
|
||||
self.out.zero_mode |= on && !self.state().nonzero;
|
||||
}
|
||||
|
||||
/// The interpreter draws a path: a fill (or a `stroke`) of the current one.
|
||||
fn painted(&mut self, stroke: bool) {
|
||||
if !self.is_visible() {
|
||||
return;
|
||||
}
|
||||
let s = self.state();
|
||||
let on = if stroke { s.stroke } else { s.fill };
|
||||
self.overprinting(on);
|
||||
self.out.paints.push(on);
|
||||
self.out.texts.push(std::mem::take(&mut self.text));
|
||||
}
|
||||
|
||||
/// The interpreter shows text.
|
||||
fn shown(&mut self) {
|
||||
if !self.is_visible() {
|
||||
return;
|
||||
}
|
||||
let s = self.state();
|
||||
self.overprinting(s.fill || s.stroke);
|
||||
let now = TextOverprint::All { fill: s.fill, stroke: s.stroke };
|
||||
self.text = if self.text == TextOverprint::None || self.text == now { now } else { TextOverprint::Mixed };
|
||||
}
|
||||
|
||||
fn note_fonts(&mut self, res: &Resources<'_>) {
|
||||
for (key, _) in res.fonts.entries() {
|
||||
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
|
||||
|
|
@ -303,6 +386,15 @@ impl Walker<'_, '_> {
|
|||
|
||||
fn walk(&mut self, mut iter: TypedIter<'_>, res: &Resources<'_>, depth: u32) {
|
||||
self.note_fonts(res);
|
||||
// The interpreter saves the state for each content stream, and restores what the stream
|
||||
// left saved when it ends.
|
||||
let saved = self.states.len();
|
||||
self.save();
|
||||
self.walk_ops(&mut iter, res, depth);
|
||||
self.states.truncate(saved);
|
||||
}
|
||||
|
||||
fn walk_ops(&mut self, iter: &mut TypedIter<'_>, res: &Resources<'_>, depth: u32) {
|
||||
while let Some(op) = iter.next() {
|
||||
self.ops += 1;
|
||||
if self.ops > self.max_ops || self.out.tags.len() >= MAX_TAGS {
|
||||
|
|
@ -338,6 +430,40 @@ impl Walker<'_, '_> {
|
|||
TypedInstruction::EndMarkedContent(_) => {
|
||||
self.visible.pop();
|
||||
}
|
||||
TypedInstruction::SaveState(_) => self.save(),
|
||||
TypedInstruction::RestoreState(_) => self.restore(),
|
||||
TypedInstruction::SetGraphicsState(gs) => {
|
||||
if let Some(d) = res.get_ext_g_state(gs.0)
|
||||
&& let Some(s) = self.states.last_mut()
|
||||
{
|
||||
let stroke = d.get::<bool>(b"OP");
|
||||
s.stroke = stroke.unwrap_or(s.stroke);
|
||||
// Without `/op`, `/OP` sets both.
|
||||
s.fill = d.get::<bool>(b"op").or(stroke).unwrap_or(s.fill);
|
||||
s.nonzero = d.get::<u8>(b"OPM").map_or(s.nonzero, |m| m != 0);
|
||||
}
|
||||
}
|
||||
TypedInstruction::FillPathEvenOdd(_) | TypedInstruction::FillPathNonZero(_) | TypedInstruction::FillPathNonZeroCompatibility(_) => {
|
||||
self.painted(false);
|
||||
}
|
||||
TypedInstruction::StrokePath(_) | TypedInstruction::CloseAndStrokePath(_) => self.painted(true),
|
||||
TypedInstruction::FillAndStrokeEvenOdd(_)
|
||||
| TypedInstruction::FillAndStrokeNonZero(_)
|
||||
| TypedInstruction::CloseFillAndStrokeEvenOdd(_)
|
||||
| TypedInstruction::CloseFillAndStrokeNonZero(_) => {
|
||||
self.painted(false);
|
||||
self.painted(true);
|
||||
}
|
||||
// Drawn when the interpreter can read the shading: the page's count tells.
|
||||
TypedInstruction::Shading(s) => {
|
||||
if res.get_shading(s.0).is_some() {
|
||||
self.painted(false);
|
||||
}
|
||||
}
|
||||
TypedInstruction::ShowText(_)
|
||||
| TypedInstruction::ShowTexts(_)
|
||||
| TypedInstruction::NextLineAndShowText(_)
|
||||
| TypedInstruction::ShowTextWithParameters(_) => self.shown(),
|
||||
TypedInstruction::XObject(x) => {
|
||||
if !self.is_visible() || depth >= MAX_DEPTH {
|
||||
continue;
|
||||
|
|
@ -360,7 +486,9 @@ impl Walker<'_, '_> {
|
|||
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
|
||||
}
|
||||
let inner = Resources::from_parent(d.get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
|
||||
self.save();
|
||||
self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1);
|
||||
self.restore();
|
||||
}
|
||||
if oc.is_some() {
|
||||
self.visible.pop();
|
||||
|
|
@ -399,8 +527,21 @@ pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &
|
|||
|
||||
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut FontNames) -> Walker<'w, 'p> {
|
||||
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
|
||||
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, max_ops: max_ops(), out: Scan::default(), hidden_form: false };
|
||||
let mut w = Walker {
|
||||
ocgs,
|
||||
off,
|
||||
xref: page.xref(),
|
||||
fonts,
|
||||
visible: vec![],
|
||||
ops: 0,
|
||||
max_ops: max_ops(),
|
||||
out: Scan::default(),
|
||||
hidden_form: false,
|
||||
states: vec![Overprint::default()],
|
||||
text: TextOverprint::None,
|
||||
};
|
||||
w.walk(page.typed_operations(), page.resources(), 0);
|
||||
w.out.texts.push(w.text);
|
||||
w
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ pub(crate) struct Look {
|
|||
pub h_scale: f64,
|
||||
pub fill: Option<Paint>,
|
||||
pub stroke: Option<(Paint, f64)>,
|
||||
/// The fill, the stroke overprints.
|
||||
pub overprint_fill: bool,
|
||||
pub overprint_stroke: bool,
|
||||
}
|
||||
|
||||
impl Look {
|
||||
|
|
@ -91,7 +94,8 @@ impl Look {
|
|||
&& (self.size - other.size).abs() <= self.size * 0.01
|
||||
&& (self.h_scale - other.h_scale).abs() < 0.5
|
||||
&& self.fill == other.fill
|
||||
&& (other.stroke.is_none() || self.stroke == other.stroke)
|
||||
&& self.overprint_fill == other.overprint_fill
|
||||
&& (other.stroke.is_none() || (self.stroke == other.stroke && self.overprint_stroke == other.overprint_stroke))
|
||||
}
|
||||
|
||||
fn style(&self) -> CharStyle {
|
||||
|
|
@ -105,6 +109,8 @@ impl Look {
|
|||
fill: self.fill.clone().unwrap_or(Paint::None),
|
||||
stroke,
|
||||
stroke_width,
|
||||
overprint_fill: self.overprint_fill,
|
||||
overprint_stroke: self.overprint_stroke,
|
||||
..CharStyle::default()
|
||||
}
|
||||
}
|
||||
|
|
@ -301,12 +307,13 @@ impl TextLine {
|
|||
}
|
||||
|
||||
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
|
||||
/// and stroke rendering): the run is stroked. `false`: it isn't that glyph.
|
||||
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool {
|
||||
/// and stroke rendering), which `overprint`s or not: the run is stroked. `false`: it isn't
|
||||
/// that glyph.
|
||||
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64), overprint: bool) -> bool {
|
||||
let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else {
|
||||
return false;
|
||||
};
|
||||
look.stroke = Some(stroke);
|
||||
(look.stroke, look.overprint_stroke) = (Some(stroke), overprint);
|
||||
true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -237,6 +237,8 @@ mod tests_import_lines;
|
|||
#[cfg(test)]
|
||||
mod tests_import_options;
|
||||
#[cfg(test)]
|
||||
mod tests_import_overprint;
|
||||
#[cfg(test)]
|
||||
mod tests_layers;
|
||||
#[cfg(test)]
|
||||
mod tests_live_text;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
//! Overprint on import (#1388): the graphics state's `/OP` (strokes), `/op` (fills, `/OP` when
|
||||
//! absent) and `/OPM` (ISO 32000-1 §8.6.7, Table 58) become the fills' and strokes' overprint.
|
||||
//! Every file here is written by hand from the specification.
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
|
||||
|
||||
use crate::tests_import_fidelity::stream;
|
||||
use crate::*;
|
||||
|
||||
const ON: &str = "/ExtGState << /Off << /OP false /op false /OPM 0 >> /On << /OP true /op true /OPM 1 >> /Stroke << /OP true /op false /OPM 1 >> /Both << /OP true /OPM 1 >> /Zero << /OP true >> >>";
|
||||
|
||||
fn open(content: &str, resources: &str, extra: &[&str]) -> ImportReport {
|
||||
let page = PdfPage { resources: format!("{ON} {resources}"), ..PdfPage::new(400.0, 400.0, content) };
|
||||
import_with_report(&pdf_with(&[page], extra, None), &ImportOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
/// Each painted object in paint order: whether its fill and its stroke overprint (`None`: it has
|
||||
/// none), type by its first run.
|
||||
fn overprints(d: &Document) -> Vec<(Option<bool>, Option<bool>)> {
|
||||
let mut out = vec![];
|
||||
d.walk(|n: &Node| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
out.extend(t.runs.first().map(|r| (Some(r.style.overprint_fill), (!r.style.stroke.is_none()).then_some(r.style.overprint_stroke))));
|
||||
} else if !n.appearance.items.is_empty() {
|
||||
out.push((n.appearance.fill().map(|f| f.overprint), n.appearance.stroke().map(|s| s.overprint)));
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn about_overprint(r: &ImportReport) -> Vec<&String> {
|
||||
r.warnings.iter().filter(|w| w.contains("overprint")).collect()
|
||||
}
|
||||
|
||||
const SPOT: &str = "/ColorSpace << /CS0 [/Separation /Spot1 /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>] >>";
|
||||
|
||||
#[test]
|
||||
fn a_spot_colour_overprinting_a_process_colour_stays_overprinting_through_export() {
|
||||
// The file of the report: a cyan square that knocks out, under a spot square that overprints.
|
||||
let r = open("/Off gs 1 0 0 0 k 50 50 200 200 re f /On gs /CS0 cs 1 scn 150 150 200 200 re f", SPOT, &[]);
|
||||
assert_eq!(overprints(&r.document), [(Some(false), None), (Some(true), None)]);
|
||||
assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings);
|
||||
let spot = r.document.layers[0].children().unwrap().iter().find(|n| n.appearance.fill().is_some_and(|f| f.overprint)).unwrap();
|
||||
assert!(format!("{:?}", spot.appearance.fill().unwrap().paint).contains("Spot1"), "the spot square is the one that overprints");
|
||||
// Written again it overprints, and comes back the same.
|
||||
let out = export(&r.document, &PdfOptions::uncompressed()).unwrap();
|
||||
assert!(String::from_utf8_lossy(&out).contains("/OP true/op true/OPM 1"));
|
||||
let again = import_with_report(&out, &ImportOptions::default()).unwrap();
|
||||
assert_eq!(overprints(&again.document).iter().filter(|o| o.0 == Some(true)).count(), 1);
|
||||
assert_eq!(overprints(&again.document).iter().filter(|o| o.0 == Some(false)).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_sets_strokes_and_fills_unless_the_state_has_its_own_fill_entry() {
|
||||
// `/OP` alone is both; with `/op false` only strokes; `B` is one object with both paints.
|
||||
let r = open("1 0 0 RG 0 0 1 rg /Both gs 10 10 50 50 re B /Stroke gs 100 10 50 50 re B 200 10 50 50 re f 200 100 50 50 re S", "", &[]);
|
||||
assert_eq!(overprints(&r.document), [(Some(true), Some(true)), (Some(false), Some(true)), (Some(false), None), (None, Some(true))]);
|
||||
assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_states_and_forms_keep_their_own_overprint() {
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 400 400]", "200 10 50 50 re f /Off gs 200 100 50 50 re f");
|
||||
let x = first_extra(1);
|
||||
let r = open(
|
||||
"0 0 1 rg q /On gs 10 10 50 50 re f Q 100 10 50 50 re f /On gs /X0 Do 300 10 50 50 re f",
|
||||
&format!("/XObject << /X0 {x} 0 R >>"),
|
||||
&[&form],
|
||||
);
|
||||
// In `q … Q`; after it; in the form (the page's state, then its own); after the form.
|
||||
assert_eq!(overprints(&r.document).iter().map(|o| o.0).collect::<Vec<_>>(), [true, false, true, false, true].map(Some));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overprint_mode_0_is_reported() {
|
||||
// No `/OPM`: mode 0, where a DeviceCMYK colour's zero components knock out.
|
||||
let r = open("/Zero gs 0 0 0 1 k 10 10 50 50 re f", "", &[]);
|
||||
assert_eq!(overprints(&r.document), [(Some(true), None)]);
|
||||
assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings);
|
||||
assert!(about_overprint(&r)[0].contains("/OPM 0"));
|
||||
// Nothing overprints: nothing to say.
|
||||
assert!(about_overprint(&open("0 0 0 1 k 10 10 50 50 re f", "", &[])).is_empty());
|
||||
}
|
||||
|
||||
const FONT: &str = "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>";
|
||||
|
||||
#[test]
|
||||
fn text_overprints_as_its_graphics_state_says() {
|
||||
// Type or its outlines, as the fonts of the machine allow.
|
||||
let r = open("0 0 1 rg 10 300 50 50 re f /On gs BT /F1 36 Tf 40 200 Td (Ink) Tj ET /Off gs 100 300 50 50 re f", FONT, &[]);
|
||||
assert_eq!(overprints(&r.document).iter().map(|o| o.0).collect::<Vec<_>>(), [false, true, false].map(Some), "{:?}", r.warnings);
|
||||
assert!(about_overprint(&r).is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_of_two_overprint_states_between_two_paths_knocks_out_with_a_warning() {
|
||||
let r = open("0 0 1 rg BT /F1 36 Tf 40 200 Td /On gs (Ink) Tj /Off gs 0 -50 Td (Paper) Tj ET", FONT, &[]);
|
||||
assert!(overprints(&r.document).iter().all(|o| o.0 == Some(false)), "{:?}", overprints(&r.document));
|
||||
assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_page_whose_paths_cant_be_counted_knocks_out_with_a_warning() {
|
||||
// A shading the interpreter can't read draws nothing: the paths after it would take the
|
||||
// overprint of the ones before them.
|
||||
let r = open("0 0 1 rg /Sh0 sh /On gs 10 10 50 50 re f /Off gs 100 10 50 50 re f", "/Shading << /Sh0 << /ShadingType 99 >> >>", &[]);
|
||||
assert_eq!(overprints(&r.document), [(Some(false), None), (Some(false), None)]);
|
||||
assert_eq!(about_overprint(&r).len(), 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_optional_content_and_pattern_cells_dont_shift_the_count() {
|
||||
let ocg = "<< /Type /OCG /Name (Hidden) >>";
|
||||
let cell = stream(
|
||||
"/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>",
|
||||
"1 0 0 rg 0 0 5 5 re f 5 5 5 5 re f",
|
||||
);
|
||||
let x = first_extra(1);
|
||||
let page = PdfPage {
|
||||
resources: format!("{ON} /Properties << /MC0 {x} 0 R >> /Pattern << /P0 {} 0 R >>", x + 1),
|
||||
..PdfPage::new(
|
||||
400.0,
|
||||
400.0,
|
||||
"/OC /MC0 BDC 0 0 1 rg 10 10 50 50 re f EMC /Pattern cs /P0 scn 100 10 50 50 re f /On gs 0 0 1 rg 200 10 50 50 re f",
|
||||
)
|
||||
};
|
||||
let catalog = format!("/OCProperties << /OCGs [{x} 0 R] /D << /OFF [{x} 0 R] >> >>");
|
||||
let bytes = vectorcraft_testkit::pdf::pdf_with_catalog(&[page], &[ocg, &cell], &catalog, None);
|
||||
for layers in [false, true] {
|
||||
let r = import_with_report(&bytes, &ImportOptions { layers, ..Default::default() }).unwrap();
|
||||
let on: Vec<_> = overprints(&r.document).into_iter().filter(|o| o.0 == Some(true)).collect();
|
||||
assert_eq!(on.len(), 1, "layers {layers}: {:?} {:?}", overprints(&r.document), r.warnings);
|
||||
assert!(about_overprint(&r).is_empty(), "layers {layers}: {:?}", r.warnings);
|
||||
}
|
||||
}
|
||||
|
|
@ -265,6 +265,8 @@ fn a_single_upright_glyph_comes_in_as_vertical_type_and_takes_no_horizontal_glyp
|
|||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
stroke: None,
|
||||
overprint_fill: false,
|
||||
overprint_stroke: false,
|
||||
};
|
||||
let at = |x: f64, y: f64| Placement { origin: Point::new(x, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
|
||||
let mut line = TextLine::new(at(100.0, 100.0), 1.0);
|
||||
|
|
@ -288,6 +290,8 @@ fn letter_spaced_vertical_type_keeps_its_characters_together() {
|
|||
h_scale: 100.0,
|
||||
fill: Some(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb(0.0, 0.0, 0.0))),
|
||||
stroke: None,
|
||||
overprint_fill: false,
|
||||
overprint_stroke: false,
|
||||
};
|
||||
// Glyphs set down a column 1.5 em apart (tracking 500): no spaces come in between them.
|
||||
let at = |y: f64| Placement { origin: Point::new(100.0, y), dir: Vec2::new(1.0, 0.0), size: 20.0, h_scale: 100.0, slant: 0.0 };
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266) · **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/mod.rs`). Illustrator's list comes from its public
|
||||
|
|
@ -56,7 +56,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
|||
| VectorCraft `.vectorcraft` / `.vctemplate` | — | yes | yes | lossless JSON, versioned, save down to v1; property-tested round trips (`format/tests/prop_format.rs`) |
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure) | PDF-compatible only | see above; no real-file corpus |
|
||||
| Illustrator template `.ait` | open, save | yes | no (save as `.vctemplate`) | |
|
||||
| PDF | open, place, save | yes (layers, masks, editable text, security; 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+) | fuzzed import; PDF text placement issues in some files (#722) |
|
||||
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint; 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+) | fuzzed import; PDF text placement issues in some files (#722) |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type and CCITTFax images not read; #505 |
|
||||
| PostScript `.ps` | open | no | via Print to PostScript | |
|
||||
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests` |
|
||||
|
|
@ -83,6 +83,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | PDF and `.ai` import, and placed PDFs, keep overprint: the graphics state's `/OP` and `/op` (ISO 32000-1 §8.6.7) become the overprint of fills, strokes and type, so a spot colour set to overprint still does after export; `/OPM 0` is reported in a note (#1388); scores unchanged |
|
||||
| 2026-10-12 | minor | `.ai` editing data: a gradient with middle stops runs its whole ramp (#1390); type of objects with an appearance, of hidden layers and on later artboards goes to its own text object (#1391); area type in a frame too short for the fallback font shows its first line (#1392); scores unchanged |
|
||||
| 2026-10-12 | minor | `.ai` editing data: placed (linked) files come in as linked images showing the preview the file keeps, so a file with them opens through its layers, with its guides (#1264); scores unchanged |
|
||||
| 2026-10-12 | minor | PDF and `.ai` import: a shape painted with a gradient opens as one path with the gradient (#1304). PDF and `.ai` export: the constant alpha soft mask of a CMYK page with transparency is white (#1286, in part) |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (`.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is
|
||||
the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md),
|
||||
|
|
@ -490,7 +490,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
|||
- **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite, and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`).
|
||||
- **Files (M4):**
|
||||
- `.vectorcraft` (lossless JSON, compressed, atomic saves, save down to v1), Save As with a format chooser, Save a Copy, Revert, templates, Data Recovery after a crash (browser tabs hold their copies with Web Locks too, so a background tab's copies are never taken for a crash's, and a tab repairs copies another removed when it resumes: #367), background save and export. The desktop app writes its warnings and errors (graphics adapter, a lost graphics device, panics the guard recovered from) to standard error and to `logs/vectorcraft.log` next to the preferences, the two previous runs kept and each file capped at 16 MiB, so a launch without a terminal still leaves something to attach to a bug report (#465).
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
- SVG/SVGZ in and out with SVG Options, Preserve Editing, symbols, filters, rich text and physical units (SVG type under a transform or `viewBox` scale opens at the size it draws at, the scale moved into its size, leading, baseline shift and strokes, a stretch, skew or rotation kept as its transform: #396; a top-level group with a clip path, such as a clipped Inkscape layer, opens as a layer with a clipping mask, its name kept: #420; hidden objects written hidden on Save and `convert`, and `data-*` attributes kept as the objects' own data: #522; top-level groups open as layers named from `data-name`, else the id, also when one more group (a transform on all the art, a `<switch>`) wraps them, hidden ones hidden, and a file whose art sits loose under a root `<svg>` with an id (or `data-name`), as one-layer exports write it, opens with its one layer named from it; nested groups stay groups, except the sublayers VectorCraft writes, marked `data-vc-layer`, and Inkscape sublayers, which open as sublayers in their layer's colour: #372); PDF and PDF-compatible `.ai` in and out (layers, with hidden ones kept hidden with their art and nested ones as sublayers, on pages of up to 64 million operators, art whose layer can't be told going to a hidden, non-printing layer rather than showing or printing: #419; strokes kept live, a fill and a stroke of one outline as one object; a type object's stroke written as each glyph's outline stroked back on the type, so it stays one editable object; lines wrapped in a frame (each wrapped line ending in a space, one left edge, leading and font) back as one area type object, left aligned or justified with its paragraph spacing, where laying it out breaks the lines where the file does; glyphs set apart by gaps wider than a space (a barcode's digit groups) keeping their places; layers stacked as the pages mark them even when one first paints on a later page, art ending a layer's marked content or a clip closing in a later layer kept in its own layer, and layers with no art kept empty: #508; art reaching past a plain PDF's page clipped to its crop box, as PDF viewers show it, each such object in a clip group of its own (a PDF-compatible `.ai` opens whole: #675); a `.ai` on its trim boxes with the bleed boxes round them as the document's bleed, as Crop To › Trim opens a PDF (#1266); masks, editable text with type set along a curve coming back as type on a path, each artboard's art on its own page only, an `.ai`'s white page under its layers left out rather than coming in as art, spot colours, presets, security, marks and bleed, ICC output intents, raster effects, subset fonts, PDF/X, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3). CMYK images keep their ink amounts from PDF import (and from placed CMYK TIFFs) to PDF export: a CMYK JPEG passes through unchanged, others stay CMYK (DeviceCMYK, or ICC-based CMYK in PDF/X-3/X-4) unless the Output settings convert them; the canvas shows them through the working CMYK profile (#320). Overprint is kept from PDF import (opened, the PDF part of an `.ai`, placed) to PDF export: the graphics state's `/OP` and `/op` become the overprint of fills, strokes and type, so a spot colour over a process colour still overprints; a page whose overprint can't be read for certain knocks out with a note, as does `/OPM 0` being read as nonzero overprint (#1388; hand-written fixtures, `pdf/src/tests_import_overprint.rs`). A masked CMYK image (`/SMask`, `/Mask` or a colour key) keeps its inks too, its alpha coming in as a greyscale opacity mask.
|
||||
A PDF saved with Illustrator's editing data opens through it as a PDF-compatible `.ai` does, with its layers and groups and with art reaching past its page unclipped; such a PDF holds the data in `AIPDFPrivateData` streams (part of #868). A shape a PDF or `.ai` paints with a gradient (a clip path round a shading) opens as one path filled with the gradient, not a clip group round a rectangle, so Shape Builder, Live Paint and Pathfinder work on its outline (#1304).
|
||||
Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document or at its path is read as the document opens). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests_links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392).
|
||||
- EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546).
|
||||
|
|
@ -519,6 +519,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
|||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | File formats: PDF and `.ai` import and placed PDFs keep overprint (`/OP`, `/op`), which exports again (#1388); own-fixture tests; scores and hours unchanged (estimated) |
|
||||
| 2026-10-12 | minor | File formats: `.ai` editing data follow-ups to #1264: gradients with middle stops (#1390), type of objects with an appearance, hidden layers and later artboards (#1391), area type in a frame too short for the fallback font (#1392); a PDF saved with its editing data opens with its bleed (#1379); score and hours unchanged (estimated) |
|
||||
| 2026-10-12 | minor | UI chrome: dialogs take the keyboard focus on every open (#1367) and give it to a first field that starts greyed out once it is enabled (#1395); Artboards: art flush with an artboard within a rounding error moves, copies and scales with it (#1375, from #1295); Type core: a character no font had is looked for again after a font rescan (#1376, from #992); scores and hours unchanged (estimated) |
|
||||
| 2026-10-12 | minor | Performance: the art placed for symbol instances and inline graphics is kept between frames (#1386; the contributor measured 51.6 → 32.3 ms a frame on 2,000 instances at 2880 × 1800, not re-measured here); budgets still unverified, score unchanged. Automation: `--control` without a valid port stops the desktop app with an error (#1383) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue