mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 13:17:19 -06:00
fix(pdf-import): take a group's knockout and isolate flags from the interpreter
The import read each form's /Group /I and /K from the file in order and matched them to the transparency groups the interpreter pushed. The interpreter draws a group that adds nothing (full opacity, Normal, no mask, neither flag) straight onto its parent without pushing it, so every group after one took another group's flags. Blend steps written as a knockout group of falling opacity then painted over each other and drew as solid dark shapes instead of light rings. The interpreter already reports the flags right after pushing a group (set_knockout, set_isolated); the import now uses those and drops the file-order list. That list was capped at 10,000 entries, past which a page failed to import; that limit goes with it.
This commit is contained in:
@@ -48,13 +48,11 @@ impl<'p> Builder<'p> {
|
||||
taken: vec![],
|
||||
scan: Scan::default(),
|
||||
tag_at: 0,
|
||||
group_at: 0,
|
||||
paint_at: 0,
|
||||
aligned: true,
|
||||
route,
|
||||
unsure: false,
|
||||
marked: vec![],
|
||||
pending: false,
|
||||
nested: 0,
|
||||
white_images: false,
|
||||
last_fill: None,
|
||||
@@ -103,8 +101,7 @@ impl<'p> Builder<'p> {
|
||||
self.mask = None;
|
||||
self.masked.clear();
|
||||
self.marked.clear();
|
||||
self.pending = false;
|
||||
(self.scan, self.tag_at, self.group_at, self.aligned, self.paint_at) = (scan, 0, 0, true, 0);
|
||||
(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);
|
||||
}
|
||||
@@ -478,14 +475,12 @@ impl<'p> Builder<'p> {
|
||||
blend: std::mem::replace(&mut self.blend, BlendMode::Normal),
|
||||
mask: self.mask.take(),
|
||||
page: std::mem::replace(&mut self.page, page),
|
||||
pending: std::mem::take(&mut self.pending),
|
||||
};
|
||||
self.nested += 1;
|
||||
read(self);
|
||||
let art = self.close_frames();
|
||||
self.nested -= 1;
|
||||
(self.stack, self.root_groups, self.blend, self.mask, self.page, self.pending) =
|
||||
(saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page, saved.pending);
|
||||
(self.stack, self.root_groups, self.blend, self.mask, self.page) = (saved.stack, saved.root_groups, saved.blend, saved.mask, saved.page);
|
||||
Some(art)
|
||||
}
|
||||
|
||||
|
||||
@@ -35,11 +35,9 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.flush_glyphs();
|
||||
let walked = self.walked(false);
|
||||
if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) {
|
||||
self.pending = false;
|
||||
return;
|
||||
}
|
||||
self.flush_text();
|
||||
self.pending = false;
|
||||
let overprint = self.path_overprint();
|
||||
let mut bp = path.clone();
|
||||
bp.apply_affine(transform);
|
||||
@@ -140,17 +138,6 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
|
||||
fn push_clip_path(&mut self, clip_path: &ClipPath) {
|
||||
self.flush();
|
||||
// A form's box right after its transparency group: the group's isolate and knockout
|
||||
// flags are the next ones read from the file.
|
||||
if std::mem::take(&mut self.pending) {
|
||||
let flags = self.scan.groups.get(self.group_at).copied();
|
||||
self.group_at += 1;
|
||||
if let Some((i, k)) = flags
|
||||
&& let Some(Frame { kind: FrameKind::Group { isolate, knockout, .. }, .. }) = self.stack.last_mut()
|
||||
{
|
||||
(*isolate, *knockout) = (i, k);
|
||||
}
|
||||
}
|
||||
// Clips that contain the whole page do nothing visible; don't create groups for them. Only
|
||||
// a polygon that really covers it counts: a diamond touching the page edges has the page
|
||||
// as its bounding box but cuts its corners away (#1542).
|
||||
@@ -165,7 +152,6 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
self.flush();
|
||||
let mask = mask.map(|m| self.soft_mask(&m));
|
||||
self.stack.push(Frame::new(FrameKind::Group { opacity, blend: blend(blend_mode), mask, isolate: false, knockout: false }));
|
||||
self.pending = self.nested == 0;
|
||||
// Blend mode applies to the group as a whole, not to its children.
|
||||
self.blend = BlendMode::Normal;
|
||||
}
|
||||
@@ -182,7 +168,6 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
if matches!(draw_mode, GlyphDrawMode::Clip) {
|
||||
return;
|
||||
}
|
||||
self.pending = false;
|
||||
let stroke = match draw_mode {
|
||||
// Invisible text (a scanned page's recognized text, over the scan) draws nothing but
|
||||
// is still text: type with neither fill nor stroke, so it stays searchable.
|
||||
@@ -241,7 +226,6 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
|
||||
self.flush_glyphs();
|
||||
self.flush_text();
|
||||
self.pending = false;
|
||||
match image {
|
||||
Image::Raster(r) => {
|
||||
let white = self.white_images;
|
||||
@@ -328,13 +312,25 @@ impl<'a> Device<'a> for Builder<'_> {
|
||||
|
||||
fn pop_clip_path(&mut self) {
|
||||
self.flush();
|
||||
self.pending = false;
|
||||
self.pop_frame();
|
||||
}
|
||||
|
||||
// The interpreter tells the flags of a form's group right after pushing it. (It draws a group
|
||||
// that adds nothing straight onto its parent, without pushing it.)
|
||||
fn set_knockout(&mut self) {
|
||||
if let Some(Frame { kind: FrameKind::Group { knockout, .. }, .. }) = self.stack.last_mut() {
|
||||
*knockout = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_isolated(&mut self) {
|
||||
if let Some(Frame { kind: FrameKind::Group { isolate, .. }, .. }) = self.stack.last_mut() {
|
||||
*isolate = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn pop_transparency_group(&mut self) {
|
||||
self.flush();
|
||||
self.pending = false;
|
||||
self.pop_frame();
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ pub(super) struct Saved {
|
||||
pub(super) blend: BlendMode,
|
||||
pub(super) mask: Option<Arc<MaskSpec>>,
|
||||
pub(super) page: Rect,
|
||||
pub(super) pending: bool,
|
||||
}
|
||||
|
||||
pub(super) struct Builder<'p> {
|
||||
@@ -129,7 +128,6 @@ pub(super) struct Builder<'p> {
|
||||
/// The page as walked by [`scan_page`], and how far the interpreter is through it.
|
||||
pub(super) scan: Scan,
|
||||
pub(super) tag_at: usize,
|
||||
pub(super) group_at: usize,
|
||||
/// The fills and strokes of paths drawn so far ([`Scan::paints`]).
|
||||
pub(super) paint_at: usize,
|
||||
pub(super) aligned: bool,
|
||||
@@ -140,8 +138,6 @@ pub(super) struct Builder<'p> {
|
||||
pub(super) unsure: bool,
|
||||
/// The marked-content sequences open: the group each marks.
|
||||
pub(super) marked: Vec<Option<usize>>,
|
||||
/// A transparency group was just pushed: a form's (whose flags come next) or an image's.
|
||||
pub(super) pending: bool,
|
||||
pub(super) nested: u32,
|
||||
/// Reading an alpha soft mask's art: images are read white with their own alpha, so the
|
||||
/// mask's luminance is their alpha, as whitened vector art's is (cairo, and so Inkscape,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
//! 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, the overprint parameters each path
|
||||
//! and each stretch of text is painted with, the Separation and DeviceN inks they are painted in
|
||||
//! (by their colorant names, which the interpreter drops), and the names of the fonts.
|
||||
//! to, the overprint parameters each path and each stretch of text is painted with, the Separation
|
||||
//! and DeviceN inks they are painted in (by their colorant names, which the interpreter drops), 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), 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.
|
||||
//! hayro-interpret reports a marked-content sequence by its tag alone 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 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.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -258,8 +256,6 @@ pub(crate) struct Scan {
|
||||
/// Each marked-content sequence: its tag ([`tag_key`]) and the group (index into
|
||||
/// [`Ocgs::list`]) it marks.
|
||||
pub tags: Vec<(u64, Option<usize>)>,
|
||||
/// Each form transparency group: isolated, knockout.
|
||||
pub groups: Vec<(bool, bool)>,
|
||||
/// Content was incomplete or a scan limit was reached. No document may be returned from
|
||||
/// this scan, since its routing no longer describes every object the interpreter draws.
|
||||
pub cut: bool,
|
||||
@@ -846,13 +842,6 @@ impl Walker<'_, '_> {
|
||||
self.hidden_form |= !self.is_visible();
|
||||
}
|
||||
if self.is_visible() {
|
||||
if let Some(g) = d.get::<Dict<'_>>(b"Group") {
|
||||
if self.out.groups.len() >= MAX_GROUPS {
|
||||
self.out.cut = true;
|
||||
return;
|
||||
}
|
||||
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
|
||||
}
|
||||
// (Its own resources, or without any those of what draws it: as the interpreter reads them.)
|
||||
let inner = Resources::from_scope(d.get::<Dict<'_>>(b"Resources"), res.clone());
|
||||
self.save();
|
||||
|
||||
@@ -241,6 +241,41 @@ fn isolate_and_knockout_flags_of_a_group_are_kept() {
|
||||
assert!(all(&d).iter().all(|n| !matches!(n.kind, NodeKind::Group { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knockout_groups_after_a_group_drawn_straight_keep_their_flag() {
|
||||
// Two knockout groups (ISO 32000-1, 11.4.6.3), each of a full-opacity element and a 50% one,
|
||||
// both elements groups of their own. A full-opacity element adds nothing as a group and is
|
||||
// drawn straight onto its parent; the knockout group after it still knocks out.
|
||||
let x = first_extra(1);
|
||||
let ring = |at: usize| {
|
||||
let res = format!("/ExtGState << /A << /ca 1 >> /B << /ca 0.5 >> >> /XObject << /E {} 0 R /F {} 0 R >>", at, at + 1);
|
||||
stream(
|
||||
&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /K true >> /Resources << {res} >>"),
|
||||
"q /A gs /E Do Q q /B gs /F Do Q",
|
||||
)
|
||||
};
|
||||
let element = |art: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>", art);
|
||||
let d = open(&one_page(
|
||||
"/X0 Do /X1 Do",
|
||||
&format!("/XObject << /X0 {x} 0 R /X1 {} 0 R >>", x + 1),
|
||||
&[
|
||||
&ring(x + 2),
|
||||
&ring(x + 4),
|
||||
&element("0 0 1 rg 10 10 40 40 re f"),
|
||||
&element("0 0 1 rg 20 20 20 20 re f"),
|
||||
&element("0 0 1 rg 50 50 40 40 re f"),
|
||||
&element("0 0 1 rg 60 60 20 20 re f"),
|
||||
],
|
||||
));
|
||||
let groups: Vec<Node> = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).collect();
|
||||
assert_eq!(groups.len(), 2, "{groups:?}");
|
||||
for g in &groups {
|
||||
assert_eq!(g.knockout, Knockout::On, "{g:?}");
|
||||
let opacities: Vec<f32> = g.children().unwrap().iter().map(|c| c.opacity).collect();
|
||||
assert_eq!(opacities, [1.0, 0.5]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- tiling patterns ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (PDF with editing data saved for some of its artboards: the layers are compared on the artboard its page shows) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (PDF import: knockout and isolated groups keep their flags after a group drawn straight onto its parent) · **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
|
||||
@@ -132,6 +132,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | minor | PDF import (and a `.ai`'s PDF part): a transparency group's knockout and isolate flags (`/K`, `/I`; ISO 32000-1, 11.4.6.3) come from the interpreter as it pushes the group. They were read from the file in order and matched to the groups pushed, but a group that adds nothing (full opacity, Normal, no mask, neither flag) is drawn straight onto its parent without being pushed, so every group after one lost its own flags and took another's; and a page drawing form groups more than 10,000 times failed to import (the list of their flags was capped), which it no longer does. A blend's steps written as a knockout group of falling opacity drew as solid dark shapes: a user's `.ai` with 143 such bubbles now draws them as light rings, as poppler does (its bubble area 4.9% → 1.2% of pixels off from poppler's render); 16 other users' PDFs with transparency groups (44 pages) draw the same. Test `tests::import_fidelity::knockout_groups_after_a_group_drawn_straight_keep_their_flag`; scores unchanged |
|
||||
| 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit. When a blend is the layer's last object and the layer's own state block (`7 () XW`, then `LB`) follows, the state is the layer's and the blend keeps its own, as for meshes (#4748); scores unchanged |
|
||||
| 2026-10-31 | minor | PDF (and `.ai`) with Illustrator editing data saved with only some of its artboards: its pages are those artboards, while the editing data keeps them all, and the layers were compared with the first page on the first artboard, so a page of the second artboard came in as the PDF part, without its layers. With fewer pages than artboards, the first page is now taken to be the artboard of its size whose art draws most like it (up to 16 weighed), and the pages' type goes to that artboard and the ones after it. All artboards come in, as before. Test `import::tests_native::a_pdf_of_its_second_artboard_is_compared_on_that_artboard`; scores unchanged |
|
||||
| 2026-10-30 | minor | `.ai`/EPS editing data: a blend takes the transparency (opacity, blend mode, knockout; blends are written with knockout on) in the state block after its record, as a group takes the one after its end; it was left for the next object, so a blend's steps of falling opacity drew over each other instead of knocking each other out, and every such blend came in too dark (a user's file has 143, its bubbles came in as dark rings). Both a live blend and drawn steps kept as a group take it. Test `import::ai::tests::a_blends_transparency_follows_its_record`; checked locally against that file: it comes 1.3% → 0.6% nearer its page, though a mesh seen through a mesh-shaped opacity mask still keeps it just over the limit; scores unchanged |
|
||||
|
||||
Reference in New Issue
Block a user