PDF import: an editor's .ai keeps its RGB numbers, and a fill faded or masked by its group keeps its stroke apart

This commit is contained in:
EricKuck 2026-10-12 11:14:00 -04:00 committed by bflatastic
parent 04bc43c802
commit 34b3c59a03
9 changed files with 103 additions and 14 deletions

View File

@ -221,7 +221,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
.then(|| vectorcraft_pdf::editing_with(bytes, opts.password.as_deref()))
.flatten()
.map(|e| (e.intact, move || Some(e.data)));
restore_or_import(editing, || match super::pdfimport::import(bytes, opts) {
restore_or_import(editing, || match super::pdfimport::import(bytes, opts, format.id != "pdf") {
Ok(r) => {
// Pages of text alone: the editing copy alone where it reads, as for a file
// saved without its PDF part (#1387: else the page, which is the art).

View File

@ -99,7 +99,7 @@ impl LoadOptions {
/// The PDF import options: `pages` resolved against the file's page count. A PDF-compatible
/// `.ai`, or a PDF saved with its editing data, asked for its crop box opens on its trim boxes,
/// its editor's artboards, with the bleed round them (its crop box takes the bleed in).
fn import_options(&self, bytes: &[u8]) -> Result<ImportOptions> {
fn import_options(&self, bytes: &[u8], editor_document: bool) -> Result<ImportOptions> {
let pages = match &self.pages {
None => None,
Some(range) => {
@ -114,6 +114,7 @@ impl LoadOptions {
password: self.password.clone(),
text_as: self.text_as,
layers: self.layers,
uncalibrated_rgb: editor_document,
..Default::default()
})
}
@ -131,8 +132,9 @@ fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result
}
/// Import the pages `o` pick → the document, import notes and what else the import tells.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<vectorcraft_pdf::ImportReport> {
vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)
/// `editor_document`: a `.ai` file.
pub(super) fn import(bytes: &[u8], o: &LoadOptions, editor_document: bool) -> Result<vectorcraft_pdf::ImportReport> {
vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes, editor_document)?).map_err(err)
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one

View File

@ -194,6 +194,7 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportRe
b.unsure = route && ocgs.list.iter().any(|g| (all_on && !g.on) || !g.print);
b.taken = taken;
b.warnings = notes;
b.colors.uncalibrated_rgb = opts.uncalibrated_rgb && picked.iter().filter_map(|&n| pages.get(n)).any(crate::pages::has_private_data);
let cache = InterpreterCache::new();
let mut x = 0.0;
// Every page only text over private data: a file saved without its PDF part shows a notice
@ -1673,9 +1674,8 @@ impl<'a> Device<'a> for Builder<'_> {
PathDrawMode::Stroke(props) => {
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.
// Fill-then-stroke of the same path (the `B` operator, or a fill and then a stroke
// of the same outline) becomes one object: a path with a fill and a live stroke.
let blend = self.blend;
let fill = self.last_fill.take();
let last = match self.mask {
@ -1687,6 +1687,10 @@ impl<'a> Device<'a> for Builder<'_> {
&& let Some((id, outline)) = fill
&& last.id == id
&& last.blend == blend
// Opacity or a mask of its own came from a transparency group the fill was
// drawn in and this stroke wasn't.
&& last.opacity == 1.0
&& last.extras().mask.is_none()
&& last.appearance.stroke().is_none()
&& same_outline(&outline, &bp)
{

View File

@ -90,6 +90,8 @@ struct Ink {
pub(crate) enum Space {
Gray,
Rgb,
/// An `ICCBased` RGB space whose numbers are taken as they are (see `Colors::uncalibrated_rgb`).
IccRgb,
Cmyk,
/// A Separation or DeviceN space (index into `Colors::spaces`).
Inks(usize),
@ -119,6 +121,10 @@ pub(crate) struct Colors<'p> {
rgb: usize,
/// A DeviceN colour mixed several inks (imported as RGB).
pub mixed: bool,
/// RGB colours in an `ICCBased` space keep their numbers instead of going through the profile:
/// on for an editor's own document (`.ai`), so its art keeps the RGB numbers the file has; off
/// for PDFs, whose profiles stand.
pub uncalibrated_rgb: bool,
}
/// `v`'s `Debug` text, or `None` past [`DEBUG_LIMIT`] (formatting stops there).
@ -152,7 +158,7 @@ pub(crate) fn split_color(t: &str) -> Option<(&str, Vec<f32>)> {
impl<'p> Colors<'p> {
pub fn new(pdf: &'p Pdf, taken: Vec<String>) -> Self {
Self { pdf, taken, scanned: false, spaces: vec![], inks: vec![], cmyk: 0, rgb: 0, mixed: false }
Self { pdf, taken, scanned: false, spaces: vec![], inks: vec![], cmyk: 0, rgb: 0, mixed: false, uncalibrated_rgb: false }
}
/// A solid colour in its own model; `None`: use its RGB.
@ -178,7 +184,7 @@ impl<'p> Colors<'p> {
fn count(&mut self, space: Space) {
match space {
Space::Cmyk => self.cmyk += 1,
Space::Rgb | Space::Other => self.rgb += 1,
Space::Rgb | Space::IccRgb | Space::Other => self.rgb += 1,
Space::Gray | Space::Inks(_) => {}
}
}
@ -200,6 +206,7 @@ impl<'p> Colors<'p> {
match (kind, n) {
("DeviceGray" | "ICCBased", 1) => Space::Gray,
("DeviceRgb", 3) => Space::Rgb,
("ICCBased", 3) if self.uncalibrated_rgb => Space::IccRgb,
("DeviceCmyk" | "ICCBased", 4) => Space::Cmyk,
("Separation", 1) | ("DeviceN", _) => {
self.scan();
@ -223,6 +230,7 @@ impl<'p> Colors<'p> {
match space {
Space::Gray => Some(Native::plain(Color::gray(round3(1.0 - c(0))))),
Space::Cmyk => Some(Native::plain(Color::cmyk(c(0), c(1), c(2), c(3)))),
Space::IccRgb => Some(Native::plain(Color::rgb(c(0), c(1), c(2)))),
Space::Rgb | Space::Other => None,
Space::Inks(i) => self.inks_color(i, comps),
}

View File

@ -122,11 +122,23 @@ pub struct ImportOptions {
/// Optional content groups become layers (else each page is one layer, without the art
/// that is off).
pub layers: bool,
/// The file is an editor's own document (`.ai`): RGB in its `ICCBased` spaces keeps its numbers
/// (see `Colors::uncalibrated_rgb`). Off for PDFs, whose profiles stand.
pub uncalibrated_rgb: bool,
}
impl Default for ImportOptions {
fn default() -> Self {
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default(), layers: true }
Self {
max_pages: None,
artboard_gap: 36.0,
pages: None,
crop: CropTo::default(),
password: None,
text_as: TextAs::default(),
layers: true,
uncalibrated_rgb: false,
}
}
}

View File

@ -245,3 +245,20 @@ fn spot_colours_with_an_icc_lab_alternate_keep_their_lab_values() {
// An RGB profile stays RGB.
assert!(near(d.swatch("Leafy").unwrap().paint.color().unwrap(), Color::rgb(0.5, 0.9, 0.1)), "{:?}", d.swatch("Leafy"));
}
#[test]
fn rgb_numbers_stand_in_editor_documents_only() {
// GUARDS: an editor's document keeps its `ICCBased` RGB numbers; a PDF's profile stands.
let f = first_extra(1);
let icc = vectorcraft_color::cms::icc_bytes(vectorcraft_color::cms::PROPHOTO_RGB).unwrap();
let hex: String = icc.iter().map(|b| format!("{b:02x}")).collect();
let profile = format!("<< /N 3 /Filter /ASCIIHexDecode /Length {} >>\nstream\n{hex}>\nendstream", hex.len() + 1);
let mut page = page_with(&format!("/CS0 [/ICCBased {f} 0 R]"), &[("CS0", "0.2 0.6 0.4")]);
page.entries = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into();
let bytes = pdf_with(&[page], &[&profile], None);
let color = |uncalibrated_rgb| {
solid(&fills(&import_with_report(&bytes, &ImportOptions { uncalibrated_rgb, ..Default::default() }).unwrap().document)[0]).0
};
assert!(near(color(true), Color::rgb(0.2, 0.6, 0.4)), "{:?}", color(true));
assert!(!near(color(false), Color::rgb(0.2, 0.6, 0.4)), "{:?}", color(false));
}

View File

@ -432,8 +432,8 @@ fn objects(d: &Document) -> Vec<Node> {
#[test]
fn an_object_written_as_a_fill_and_a_closed_stroke_stays_one_path_with_a_live_stroke() {
// As Illustrator writes an object: the fill of the outline left open, then the stroke of it
// closed, each in its own transform. Two subpaths: one ending on its start, one not.
// The fill of the outline left open, then the stroke of it closed, each in its own
// transform. Two subpaths: one ending on its start, one not.
let outline = "0 0 m -20 1 l -30 -12 l -30 30 l 0 0 l 5 5 m 10 5 l 10 10 l";
let content = format!(
"0.2 0.2 0.2 rg q 1 0 0 1 50 50 cm {outline} f Q 1 1 1 RG 1.5 w 1 J 1 j 4 M [3 2] 1 d q 1 0 0 1 50 50 cm {} S Q",
@ -457,6 +457,45 @@ fn an_object_written_as_a_fill_and_a_closed_stroke_stays_one_path_with_a_live_st
assert!(p.is_closed());
}
#[test]
fn a_fill_in_a_translucent_group_doesnt_take_the_stroke_drawn_after_it() {
// A fill at 75 % under an opaque stroke of the same outline: the fill in a transparency group
// drawn at 75 %, the stroke after the group.
let x = first_extra(1);
let outline = "10 10 m 60 10 l 60 60 l 10 60 l h";
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", &format!("1 0 0 rg {outline} f"));
let content = format!("q /G gs /X0 Do Q 0 0 1 RG 2 w {outline} S");
let d = open(&one_page(&content, &format!("/ExtGState << /G << /ca 0.75 /CA 0.75 >> >> /XObject << /X0 {x} 0 R >>"), &[&form]));
let objs = objects(&d);
let stroke = objs.iter().find(|n| n.appearance.stroke().is_some()).expect("a stroke");
let alpha = all(&d)
.iter()
.filter(|n| n.appearance.stroke().is_some() || n.children().is_some_and(|c| c.iter().any(|k| k.id == stroke.id)))
.map(|n| n.opacity)
.product::<f32>();
assert_eq!(alpha, 1.0, "the stroke stays opaque: {objs:#?}");
let fill = objs.iter().find(|n| n.appearance.fill().is_some()).expect("a fill");
assert!((fill.opacity * fill.appearance.fill().unwrap().opacity - 0.75).abs() < 1e-3, "{fill:#?}");
}
#[test]
fn a_fill_in_a_soft_masked_group_doesnt_take_the_stroke_drawn_after_it() {
// ISO 32000-1 §11.6.5.2: the soft mask in the graphics state applies to the group drawn under
// it, not to the stroke after `Q`.
let (m, x) = (first_extra(1), first_extra(1) + 1);
let outline = "10 10 m 60 10 l 60 60 l 10 60 l h";
let mask = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >>", "1 1 1 rg 0 0 35 100 re f");
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", &format!("1 0 0 rg {outline} f"));
let content = format!("q /GS gs /X0 Do Q 0 0 1 RG 2 w {outline} S");
let resources = format!("/ExtGState << /GS << /SMask << /Type /Mask /S /Luminosity /G {m} 0 R >> >> >> /XObject << /X0 {x} 0 R >>");
let d = open(&one_page(&content, &resources, &[&mask, &form]));
let nodes = all(&d);
let stroke = nodes.iter().find(|n| n.appearance.stroke().is_some()).expect("a stroke");
assert!(stroke.appearance.fill().is_none() && stroke.extras().mask.is_none(), "the stroke is its own unmasked object: {stroke:#?}");
let masked = nodes.iter().filter(|n| n.extras().mask.is_some()).collect::<Vec<_>>();
assert!(!masked.is_empty() && masked.iter().all(|n| n.appearance.stroke().is_none()), "{masked:#?}");
}
#[test]
fn different_outlines_filled_and_stroked_stay_apart() {
let d = open(&one_page("1 0 0 rg 10 10 m 40 10 l 40 40 l f 0 0 1 RG 10 10 m 40 10 l 40 40 l 10 40 l h S 0 g 50 50 m 60 60 l S", "", &[]));

View File

@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a `.ai` whose PDF part is only text opens from it, #1387; 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)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (a `.ai` whose PDF part is only text opens from it, #1387; 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; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **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
@ -32,6 +32,9 @@ private editing data. VectorCraft:
Level 3 interpreter. A `.ai` in PostScript form (versions 3 to 8, and what Rhino and other CAD apps
write) is its own editing data: it opens through the layers its program has, compared with its page
when the program runs, from the layers alone when it names its prolog without including it (#1027).
RGB in the PDF page's `ICCBased` spaces keeps the file's numbers in a `.ai` (the `uncalibrated_rgb`
import option, on for editor documents, off for PDFs), so `.ai` files tagged with a non-sRGB RGB profile
open with different, unconverted RGB colours than before.
A PDF-compatible `.ai` opens on its PDF part's trim boxes (its artboards), the bleed boxes round them
becoming the document's bleed, so art in the bleed shows outside the artboard, within the bleed guide (#1266).
A PDF saved with its editing data (`AIPDFPrivateData`) opens the same way.
@ -83,6 +86,8 @@ 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 | `.ai` read as its PDF part keeps the RGB numbers of its `ICCBased` spaces (`uncalibrated_rgb`, on for `.ai`, off for PDFs): a user-visible colour change for `.ai` files tagged with a non-sRGB RGB profile |
| 2026-10-12 | minor | PDF import: a fill that took its opacity or soft mask from a transparency group no longer absorbs the same-outline stroke drawn after the group, so the stroke keeps neither |
| 2026-10-12 | minor | A `.ai` whose PDF part draws only text opens from that part when its editing data can't be read, instead of being refused as saved without PDF compatibility: a page that states an art, trim or bleed box is art, not the placeholder (#1387); scores unchanged |
| 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 |

View File

@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; 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; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from
switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make
@ -234,6 +234,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | G9: `.ai` keeps the RGB numbers of its `ICCBased` spaces (`uncalibrated_rgb`; colours change for non-sRGB-profiled `.ai` files); estimate unchanged |
| 2026-10-12 | minor | G9: PDF import no longer merges a stroke into a fill that got its opacity or soft mask from a transparency group; estimate unchanged |
| 2026-10-12 | minor | G9: in `.ai` editing data, gradients with middle stops (#1390), type of objects with an appearance, hidden layers and later artboards (#1391) and area type in a frame too short for the fallback font (#1392) open as saved; G15: the art placed for symbol instances and inline graphics is kept between frames (#1386; the budget run on an idle machine remains); G1: dialogs take the keyboard focus on every open (#1367, #1395); estimates unchanged |
| 2026-10-12 | minor | G9: placed (linked) files in `.ai` editing data come in as linked images showing their saved preview, so such files open through their layers with their guides (#1264); estimate unchanged |
| 2026-10-12 | minor | G9: gradient-filled shapes from a PDF or `.ai` open as paths, not clip groups (#1304); CMYK files with transparency show in applications reading soft masks by luminosity (#1286, in part: the layer groups' bounding boxes remain); G15: hiding or locking a layer deselects in linear time (#1353); estimates unchanged |