Merge pull request #2218 from dotCooCoo/fix/same-value-spot-inks

PDF/.ai import: spot inks with the same values and different names keep their own swatches
This commit is contained in:
BFlat
2026-10-16 04:00:54 +07:00
committed by GitHub
7 changed files with 577 additions and 69 deletions
+11 -1
View File
@@ -587,6 +587,8 @@ fn arb_pdf_op() -> impl Strategy<Value = String> {
Just("/GS0".to_string()),
Just("/DeviceRGB".to_string()),
Just("/CS0".to_string()),
Just("/CS1".to_string()),
Just("/P0".to_string()),
Just("/DeviceCMYK".to_string()),
Just("/Pattern".to_string()),
Just("(Hi)".to_string()),
@@ -611,6 +613,9 @@ fn arb_resources() -> impl Strategy<Value = String> {
"<< /ColorSpace << /CS0 [/DeviceN [] /DeviceGray << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(),
"<< /ColorSpace << /CS0 [/DeviceN [/A /None /Cyan /All] [/ICCBased << /N 4 >>] << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>] >> >>".to_string(),
"<< /Shading << /Sh0 << /ShadingType 2 /ColorSpace [/Separation /Ink /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>] /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> >>".to_string(),
// Inks with the same values and other names, as color spaces, a shading pattern and a
// shading (the page's walk tells them apart).
"<< /ColorSpace << /CS0 [/Separation /A /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>] /CS1 [/Separation /B /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>] >> /Pattern << /P0 << /PatternType 2 /Shading << /ShadingType 2 /ColorSpace [/Separation /B /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>] /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> >> /Shading << /Sh0 << /ShadingType 2 /ColorSpace [/Separation /A /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>] /Coords [0 0 100 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> >>".to_string(),
])
}
@@ -730,7 +735,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 << /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] >>"
"/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] /CS1 [/Separation /A /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>] /CS2 [/Separation /B /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>] >>"
);
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();
@@ -756,6 +761,11 @@ fn arb_rich_op() -> impl Strategy<Value = String> {
"/GS3 gs",
"/CS0 cs 1 0 0 /P0 scn",
"/Pattern cs /P0 scn",
// Inks with the same values and other names: set, and a pattern set in one.
"/CS1 cs 1 scn",
"/CS2 cs 0.5 scn",
"/CS2 CS 1 SCN",
"/CS1 cs /P0 scn",
"/Sh0 sh",
"/Sh1 sh",
"BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET",
+67 -17
View File
@@ -12,6 +12,7 @@ use craft_pdf::interpret::{
MaskType, PathDrawMode, SoftMask, StrokeProps,
};
use craft_pdf::syntax::object::Name;
use craft_pdf::syntax::page::Page;
use kurbo::{Affine, BezPath, PathEl, Rect, Shape};
use vectorcraft_color::{BlendMode, Color, Paint, Swatch};
use vectorcraft_doc::{
@@ -22,7 +23,7 @@ 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, TextOverprint, all_on, hides_forms, scan_page, tag_key};
use crate::import_scan::{FontNames, MAX_NESTING, Ocgs, Scan, TextOverprint, Walked, 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};
@@ -258,7 +259,14 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
b.page = ab;
// The first id of this page's art.
let first_id = b.next;
let scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts, pdf.decode_limits().max_decoded_stream())?;
let mut scan = scan_page(page, &mut ocgs, all_on, &mut b.fonts, pdf.decode_limits().max_decoded_stream())?;
// The walk's ink names pick between spaces of inks that look the same to the interpreter. A
// page keeps them only when it uses such an ink and the interpreter draws as many paths
// there as the walk counts.
let tied = scan.inks.iter().any(|n| b.colors.tied(n));
if !(tied && drawn_paths(page, Context::new(xf, ab, &cache, pdf.xref(), settings.clone())) == Some(scan.paints.len())) {
scan.inks.clear();
}
if route {
let mut seen = HashSet::new();
let marked: Vec<usize> = scan.tags.iter().filter_map(|(_, g)| g.map(|g| top_group(g, &ocgs))).filter(|g| seen.insert(*g)).collect();
@@ -457,6 +465,30 @@ fn read_with_reader_options(bytes: &[u8], opts: &ImportOptions, reader: &craft_p
Ok(ImportReport { document: doc, warnings, native: crate::editing::editing_in(&pdf), only_text, user_unit })
}
/// How many paths the interpreter draws on `page` (each fill and each stroke), leaving out those
/// of soft masks, pattern cells and type 3 glyphs, which [`Builder`] reads nested. `None` when the
/// interpreter fails.
fn drawn_paths<'a>(page: &'a Page<'a>, mut ctx: Context<'a>) -> Option<usize> {
struct Count(usize);
impl<'a> Device<'a> for Count {
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
fn set_blend_mode(&mut self, _: craft_pdf::interpret::BlendMode) {}
// As `Builder::draw_path` takes them: each fill and each stroke.
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &craft_pdf::interpret::Paint<'a>, _: &PathDrawMode) {
self.0 += 1;
}
fn push_clip_path(&mut self, _: &ClipPath) {}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: craft_pdf::interpret::BlendMode) {}
fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &craft_pdf::interpret::Paint<'a>, _: &GlyphDrawMode) {}
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
let mut count = Count(0);
craft_pdf::interpret_page(page, &mut ctx, &mut count).ok()?;
Some(count.0)
}
/// The characters of a CID-keyed CFF font embedded without a ToUnicode map (as macOS writes
/// Hiragino): each glyph's CID from the font's charset, then Adobe's CID → Unicode table of its
/// character collection (Japan1, GB1, CNS1, Korea1).
@@ -1024,13 +1056,20 @@ impl<'p> Builder<'p> {
}
}
/// Whether the path the interpreter draws now (a fill or a stroke, each counted) overprints.
/// Whether the path the interpreter draws now (a fill or a stroke, each counted) overprints. It
/// takes the path's turn in the page's walk.
fn path_overprint(&mut self) -> bool {
if self.nested > 0 || !self.scan.overprints {
if self.nested > 0 {
return false;
}
self.paint_at += 1;
self.scan.paints.get(self.paint_at - 1).copied().unwrap_or(false)
self.scan.overprints && self.scan.paints.get(self.paint_at - 1).copied().unwrap_or(false)
}
/// What the interpreter draws now, as the page's walk counts it: the path whose turn comes
/// next, or the `text` before it. `None` in a nested reading, which the walk doesn't follow.
fn walked(&self, text: bool) -> Option<Walked> {
(self.nested == 0).then_some(if text { Walked::Text(self.paint_at) } else { Walked::Path(self.paint_at) })
}
/// Whether the glyph the interpreter draws now (its fill, or its `stroke`) overprints.
@@ -1291,13 +1330,20 @@ impl<'p> Builder<'p> {
/// A stroke of the outline of the glyph just drawn, in the glyph's own paint, while every glyph
/// before it on the open line was stroked so too, is held with the line instead of ending it
/// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the
/// type). `false`: it isn't one.
fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'_>, draw_mode: &PathDrawMode) -> bool {
/// type). `false`: it isn't one. `walked`: the path in the page's walk.
fn hold_glyph_stroke(
&mut self,
path: &BezPath,
transform: Affine,
paint: &craft_pdf::interpret::Paint<'_>,
draw_mode: &PathDrawMode,
walked: Option<Walked>,
) -> bool {
let PathDrawMode::Stroke(props) = draw_mode else { return false };
if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, craft_pdf::interpret::Paint::Pattern(_)) {
return false;
}
let (p, opacity) = self.paint(paint, true);
let (p, opacity) = self.paint(paint, true, walked);
let Some(line) = &self.text else { return false };
let Some(ink) = line.ink.last() else { return false };
if line.held.len() + 1 != line.ink.len() || opacity < 0.999 || !line.last_fill().is_some_and(|f| *f == p) {
@@ -1320,16 +1366,19 @@ impl<'p> Builder<'p> {
true
}
/// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke).
fn paint(&mut self, p: &craft_pdf::interpret::Paint<'_>, stroke: bool) -> (Paint, f32) {
/// Convert a hayro paint to a VectorCraft paint and opacity (`stroke`: for a stroke; `walked`:
/// what it paints in the page's walk, which names its ink).
fn paint(&mut self, p: &craft_pdf::interpret::Paint<'_>, stroke: bool, walked: Option<Walked>) -> (Paint, f32) {
let pattern = matches!(p, craft_pdf::interpret::Paint::Pattern(_));
let inks = walked.and_then(|w| self.scan.ink(w, stroke, pattern));
match p {
craft_pdf::interpret::Paint::Color(c) => {
let [r, g, b, a] = c.to_rgba().components();
let paint = self.colors.solid(c).map_or_else(|| Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), Native::paint);
let paint = self.colors.solid(c, inks).map_or_else(|| Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), Native::paint);
(paint, a)
}
craft_pdf::interpret::Paint::Pattern(pat) => match pat.as_ref() {
Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors) {
Pattern::Shading(sp) => match shading_gradient(sp, &mut self.colors, inks) {
Some((g, _)) => (Paint::Gradient(Box::new(g)), sp.opacity),
None => {
self.warn("function-based shadings (and very large meshes) are imported as a flat colour");
@@ -1657,7 +1706,7 @@ impl<'p> Builder<'p> {
None if self.matched.contains_key(&key) => self.versions.entry(key).or_insert_with(|| named(&info.face)).clone(),
None => named(&info.face),
};
let (paint, opacity) = self.paint(paint, stroke.is_some());
let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true));
let stroke = stroke.map(|p| (paint.clone(), p.line_width as f64 * scale));
let look = Look {
font: key,
@@ -1743,7 +1792,8 @@ impl<'a> Device<'a> for Builder<'_> {
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &craft_pdf::interpret::Paint<'a>, draw_mode: &PathDrawMode) {
self.flush_glyphs();
if self.hold_glyph_stroke(path, transform, paint, draw_mode) {
let walked = self.walked(false);
if self.hold_glyph_stroke(path, transform, paint, draw_mode, walked) {
self.pending = false;
return;
}
@@ -1764,14 +1814,14 @@ impl<'a> Device<'a> for Builder<'_> {
};
// Patch and triangle meshes are gradient meshes.
if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading)
&& let Some(meshes) = mesh_shading(sp, &mut self.colors)
&& let Some(meshes) = mesh_shading(sp, &mut self.colors, walked.and_then(|w| self.scan.ink(w, false, true)))
{
self.draw_meshes(meshes, &bp, sp.opacity);
return;
}
let pd = PathData::from_bezpath(&bp);
let stroke = matches!(draw_mode, PathDrawMode::Stroke(_));
let (paint, opacity) = self.paint(paint, stroke);
let (paint, opacity) = self.paint(paint, stroke, walked);
// A gradient that doesn't extend past its ends paints only between them.
let width = match draw_mode {
PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform),
@@ -1892,7 +1942,7 @@ impl<'a> Device<'a> for Builder<'_> {
self.flush_text();
let mut bp = o.outline();
bp.apply_affine(transform * glyph_transform);
let (paint, opacity) = self.paint(paint, stroke.is_some());
let (paint, opacity) = self.paint(paint, stroke.is_some(), self.walked(true));
let same = self.glyphs.as_ref().is_some_and(|r| {
r.paint == paint
&& r.opacity == opacity
+64 -23
View File
@@ -5,13 +5,16 @@
//! hayro-interpret keeps a colour's space and components private: their `Debug` form is the only
//! public view, so [`Colors`] reads that (`tests_import_color.rs` pins the format). Ink names
//! aren't in it at all: they come from the file's Separation and DeviceN arrays, found by their
//! tint transform.
//! tint transform. Where spaces of inks with other names have the same tint transform, the names
//! that the page's walk ([`crate::import_scan`]) finds for each path and run of text pick one.
use std::collections::{HashMap, HashSet};
use std::fmt::{self, Debug, Write as _};
use craft_pdf::interpret::Function;
use craft_pdf::interpret::color::ColorSpace;
use craft_pdf::syntax::Pdf;
use craft_pdf::syntax::object::array::FlexArrayIter;
use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, Object, Stream};
use craft_pdf::syntax::reader::{Reader, ReaderExt};
use smallvec::SmallVec;
@@ -116,6 +119,9 @@ pub(crate) struct Colors<'p> {
taken: Vec<String>,
scanned: bool,
spaces: Vec<InkSpace>,
/// The colorant names of the spaces that have the kind, colorant count and tint transform of a
/// space of other inks ([`Colors::tied`]; found once).
tied: Option<HashSet<Vec<String>>>,
inks: Vec<Ink>,
/// Process colours painted in CMYK and in RGB (or anything converted to it).
cmyk: usize,
@@ -159,14 +165,15 @@ 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, uncalibrated_rgb: false }
Self { pdf, taken, scanned: false, spaces: vec![], tied: None, inks: vec![], cmyk: 0, rgb: 0, mixed: false, uncalibrated_rgb: false }
}
/// A solid colour in its own model; `None`: use its RGB.
pub fn solid(&mut self, c: &craft_pdf::interpret::color::Color) -> Option<Native> {
/// A solid color in its own model; `None`: use its RGB. `inks`: the colorant names of the
/// space the page's walk found it painted in ([`Colors::space_of`]).
pub fn solid(&mut self, c: &craft_pdf::interpret::color::Color, inks: Option<&[String]>) -> Option<Native> {
let mapped = debug_text(c).and_then(|t| {
let (space, comps) = split_color(&t)?;
let space = self.space_of(space, comps.len());
let space = self.space_of(space, comps.len(), inks);
Some((space, comps))
});
let (space, comps) = mapped.unwrap_or((Space::Other, vec![]));
@@ -174,9 +181,10 @@ impl<'p> Colors<'p> {
self.native(space, &comps)
}
/// The space shading colours are in, for [`Colors::native`]; `n`: its component count.
pub fn shading_space(&mut self, cs: &ColorSpace, n: usize) -> Space {
let space = debug_text(cs).map_or(Space::Other, |t| self.space_of(&t, n));
/// The space shading colors are in, for [`Colors::native`]; `n`: its component count. `inks`
/// as for [`Colors::solid`].
pub fn shading_space(&mut self, cs: &ColorSpace, n: usize, inks: Option<&[String]>) -> Space {
let space = debug_text(cs).map_or(Space::Other, |t| self.space_of(&t, n, inks));
self.count(space);
space
}
@@ -200,8 +208,10 @@ impl<'p> Colors<'p> {
self.inks.iter().filter(|i| i.used).map(|i| Swatch { name: i.name.clone(), paint: Paint::solid(i.color), global: true, spot: true })
}
/// The space a `ColorSpace(…)` `Debug` text names, with `n` components.
pub(crate) fn space_of(&mut self, text: &str, n: usize) -> Space {
/// The space a `ColorSpace(…)` `Debug` text names, with `n` components. `inks`: the colorant
/// names of the space the page's walk found the color painted in; of the spaces whose tint
/// transform matches, the one with these names is taken.
pub(crate) fn space_of(&mut self, text: &str, n: usize, inks: Option<&[String]>) -> Space {
let inner = text.strip_prefix("ColorSpace(").unwrap_or(text);
let kind = inner.split(|c: char| !c.is_ascii_alphanumeric()).next().unwrap_or_default();
match (kind, n) {
@@ -212,11 +222,15 @@ impl<'p> Colors<'p> {
("Separation", 1) | ("DeviceN", _) => {
self.scan();
let device_n = kind == "DeviceN";
let mut found =
self.spaces.iter().enumerate().filter(|(_, s)| s.device_n == device_n && s.colorants.len() == n && inner.contains(&s.key));
let like = |s: &InkSpace| s.device_n == device_n && s.colorants.len() == n && inner.contains(&s.key);
if let Some(i) = inks.and_then(|names| self.spaces.iter().position(|s| like(s) && s.names == names)) {
return Space::Inks(i);
}
let mut found = self.spaces.iter().enumerate().filter(|(_, s)| like(s));
match found.next() {
// Spaces of other inks with the same alternate and tint transform look the
// same here: which inks this colour uses can't be told.
// same here: without names from the walk, which inks this color uses can't be
// told.
Some((i, first)) if found.all(|(_, s)| s.names == first.names) => Space::Inks(i),
_ => Space::Other,
}
@@ -225,6 +239,26 @@ impl<'p> Colors<'p> {
}
}
/// Whether a space of inks `names` has the kind, colorant count and tint transform of a space
/// of other inks. [`Colors::space_of`] tells such spaces apart only by the names from the
/// page's walk.
pub fn tied(&mut self, names: &[String]) -> bool {
self.scan();
let spaces = &self.spaces;
let tied = self.tied.get_or_insert_with(|| {
let mut first: HashMap<(bool, usize, &str), &Vec<String>> = HashMap::new();
let mut tied = HashSet::new();
for s in spaces {
let f = *first.entry((s.device_n, s.colorants.len(), s.key.as_str())).or_insert(&s.names);
if *f != s.names {
tied.extend([f.clone(), s.names.clone()]);
}
}
tied
});
tied.contains(names)
}
/// Components `comps` of `space` in their own model; `None`: use their RGB.
pub fn native(&mut self, space: Space, comps: &[f32]) -> Option<Native> {
let c = |i: usize| round3(comps.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0));
@@ -324,16 +358,7 @@ impl<'p> Colors<'p> {
/// Record `a` when it is a `[/Separation name alt tint]` or `[/DeviceN [names] alt tint …]` space.
fn ink_space(&mut self, a: &Array<'_>) -> Option<()> {
let mut it = a.flex_iter();
let device_n = match it.next::<Name<'_>>()?.as_str() {
"Separation" => false,
"DeviceN" => true,
_ => return None,
};
let names: Vec<String> = if device_n {
it.next::<Array<'_>>()?.iter::<Name<'_>>().map(|n| n.as_str().to_string()).collect()
} else {
vec![it.next::<Name<'_>>()?.as_str().to_string()]
};
let (device_n, names) = ink_names(&mut it)?;
let alt = alt_model(&it.next::<Object<'_>>()?);
let tint = Function::new(&it.next::<Object<'_>>()?)?;
let key = debug_text(&tint)?;
@@ -379,6 +404,22 @@ impl<'p> Colors<'p> {
}
}
/// The colorant names of a `[/Separation name …]` or `[/DeviceN [names] …]` color space, read from
/// `it` (the items of its array), and whether it is a DeviceN one; `None` for other spaces.
pub(crate) fn ink_names(it: &mut FlexArrayIter<'_>) -> Option<(bool, Vec<String>)> {
let device_n = match it.next::<Name<'_>>()?.as_str() {
"Separation" => false,
"DeviceN" => true,
_ => return None,
};
let names = if device_n {
it.next::<Array<'_>>()?.iter::<Name<'_>>().map(|n| n.as_str().to_string()).collect()
} else {
vec![it.next::<Name<'_>>()?.as_str().to_string()]
};
Some((device_n, names))
}
/// An object written in place (references are visited as objects of their own).
/// Can every item of `a` be read? Not in a damaged file, and hayro's own iterator over the items
/// unwraps each read and panics there.
+235 -13
View File
@@ -1,7 +1,8 @@
//! 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, and the names of the fonts.
//! 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
@@ -23,6 +24,7 @@ use craft_pdf::syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object,
use craft_pdf::syntax::page::{Page, Resources};
use crate::PdfError;
use crate::import_color::ink_names;
/// The interpreter's limit on nested form XObjects.
const MAX_DEPTH: u32 = 50;
@@ -39,6 +41,10 @@ const MAX_GROUPS: usize = 10_000;
/// Match the interpreter's ordinary content window and maximum incomplete instruction.
const CONTENT_WINDOW: usize = 64 << 10;
const MAX_WINDOW: usize = 256 << 20;
/// The most Separation and DeviceN inks noted per page.
const MAX_INKS: usize = 4096;
/// The most changes of ink noted per page in the paths, and in the text (24 bytes each).
const MAX_INK_CHANGES: usize = 2_000_000;
/// The deepest layers nest in the configuration's `/Order`.
pub(crate) const MAX_NESTING: usize = 32;
@@ -234,11 +240,115 @@ pub(crate) struct Scan {
/// 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).
/// Something on the page overprints (when nothing does, the overprint flags in `paints` and
/// `texts` aren't read).
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,
/// The colorant names of the Separation and DeviceN spaces the page sets (`cs`, `CS`), and of
/// those of the shading patterns it sets (`scn`, `SCN`) and of the shadings it draws (`sh`),
/// each once (none when the page uses more than [`MAX_INKS`] inks or changes ink more than
/// [`MAX_INK_CHANGES`] times).
pub inks: Vec<Vec<String>>,
/// The inks each of [`Self::paints`] is painted in.
paint_inks: Changes<Inks>,
/// The inks of the text before each of [`Self::paints`], counted as [`Self::texts`] is: of its
/// fills and of its strokes (indices into [`Self::inks`]), where all of its text shown in an
/// ink is shown in the same one.
text_inks: Changes<[Option<u32>; 2]>,
}
impl Scan {
/// The colorant names of the ink the walk found `at` painted in: a path's, or the fills' (the
/// `stroke`s') of the text before a path. `pattern`: the interpreter paints with a pattern,
/// whose shading's ink it is (text painted with one isn't followed); else the color space's.
pub fn ink(&self, at: Walked, stroke: bool, pattern: bool) -> Option<&[String]> {
let i = match at {
Walked::Path(k) => {
let inks = self.paint_inks.at(k);
if pattern { inks.pattern } else { inks.space }
}
Walked::Text(_) if pattern => None,
Walked::Text(k) => self.text_inks.at(k)[usize::from(stroke)],
}?;
self.inks.get(usize::try_from(i).ok()?).map(Vec::as_slice)
}
}
/// The inks a fill or a stroke paints in (indices into [`Scan::inks`]): that of the color space
/// set last (`cs`, `CS`), and that of the shading of the pattern named last (`scn`, `SCN`). The
/// interpreter's paint, a color or a pattern, says which of the two it is.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Inks {
space: Option<u32>,
pattern: Option<u32>,
}
/// What the interpreter draws, counted as the walk counts it.
#[derive(Clone, Copy, Debug)]
pub(crate) enum Walked {
/// The `k`-th path ([`Scan::paints`]).
Path(usize),
/// The text shown before the `k`-th path ([`Scan::texts`]).
Text(usize),
}
/// A value for each index from 0, kept as the indices where it changes (the default before the
/// first).
#[derive(Default)]
struct Changes<T>(Vec<(usize, T)>);
impl<T: Copy + Default + PartialEq> Changes<T> {
/// The value is `v` from index `at` on (`at` past the indices set before). `false`: the value
/// changes there and [`MAX_INK_CHANGES`] changes are kept already, so this one isn't kept.
fn set(&mut self, at: usize, v: T) -> bool {
if self.0.last().map_or(T::default(), |(_, last)| *last) == v {
return true;
}
if self.0.len() >= MAX_INK_CHANGES {
return false;
}
self.0.push((at, v));
true
}
/// The value at index `k`.
fn at(&self, k: usize) -> T {
let n = self.0.partition_point(|(at, _)| *at <= k);
n.checked_sub(1).and_then(|i| self.0.get(i)).map_or(T::default(), |(_, v)| *v)
}
}
/// The ink of the text shown between two painted paths: of its fills, or of its strokes.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum RunInk {
/// No text is shown in an ink.
#[default]
None,
/// All of the text shown in an ink is shown in this one.
One(u32),
/// Text is shown in several inks, which can't be told apart by glyph.
Several,
}
impl RunInk {
/// With more text shown in `ink` (`None`: in no ink).
fn and(self, ink: Option<u32>) -> Self {
match (self, ink) {
(_, None) => self,
(Self::None, Some(i)) => Self::One(i),
(Self::One(j), Some(i)) if i == j => self,
_ => Self::Several,
}
}
fn ink(self) -> Option<u32> {
match self {
Self::One(i) => Some(i),
_ => None,
}
}
}
/// How the text shown between two painted paths overprints.
@@ -253,14 +363,16 @@ pub(crate) enum TextOverprint {
Mixed,
}
/// The overprint parameters of the graphics state (ISO 32000-1 §8.6.7; `OP`, `op` and `OPM` in
/// Table 58).
/// What the walk follows of the graphics state: the overprint parameters (ISO 32000-1 §8.6.7;
/// `OP`, `op` and `OPM` in Table 58) and the inks fills and strokes paint in.
#[derive(Clone, Copy, Default)]
struct Overprint {
struct State {
stroke: bool,
fill: bool,
/// Overprint mode 1 (nonzero overprint).
nonzero: bool,
/// The inks of fills and of strokes.
inks: [Inks; 2],
}
/// A marked-content tag as [`Scan::tags`] keeps it: its FNV-1a hash (the tags are only compared,
@@ -331,11 +443,17 @@ 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 saved graphics states' overprint parameters and inks, the current one last, pushed and
/// popped as the interpreter does its states.
states: Vec<State>,
/// The text shown since the last painted path.
text: TextOverprint,
/// The inks of the text shown since the last painted path: of its fills, of its strokes.
text_inks: [RunInk; 2],
/// The index in [`Scan::inks`] of each list of colorant names.
ink_index: HashMap<Vec<String>, u32>,
/// More inks or changes of ink than are kept: the page's inks are left out.
inks_cut: bool,
}
impl Walker<'_, '_> {
@@ -372,7 +490,7 @@ impl Walker<'_, '_> {
self.begin(on);
}
fn state(&self) -> Overprint {
fn state(&self) -> State {
self.states.last().copied().unwrap_or_default()
}
@@ -400,8 +518,11 @@ impl Walker<'_, '_> {
let s = self.state();
let on = if stroke { s.stroke } else { s.fill };
self.overprinting(on);
let k = self.out.paints.len();
self.out.paints.push(on);
self.out.texts.push(std::mem::take(&mut self.text));
self.inks_cut |= !self.out.paint_inks.set(k, s.inks[usize::from(stroke)]);
self.inks_cut |= !self.out.text_inks.set(k, std::mem::take(&mut self.text_inks).map(RunInk::ink));
}
/// The interpreter shows text.
@@ -413,6 +534,64 @@ impl Walker<'_, '_> {
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 };
// Text painted with a pattern isn't followed, so only the color spaces' inks count.
for (run, inks) in self.text_inks.iter_mut().zip(s.inks) {
*run = run.and(inks.space);
}
}
/// The color space of fills (or of `stroke`s) is in ink `ink` from now on.
fn set_space(&mut self, stroke: bool, ink: Option<u32>) {
if let Some(s) = self.states.last_mut() {
s.inks[usize::from(stroke)].space = ink;
}
}
/// The pattern of fills (or of `stroke`s) is in ink `ink` from now on.
fn set_pattern(&mut self, stroke: bool, ink: Option<u32>) {
if let Some(s) = self.states.last_mut() {
s.inks[usize::from(stroke)].pattern = ink;
}
}
/// The ink of color space `name` (`cs`, `CS`): a Separation or DeviceN space of the resources.
/// The names the interpreter reads as device and pattern spaces come first, as it reads them.
fn space_ink(&mut self, name: &Name<'_>, res: &Resources<'_>) -> Option<u32> {
if matches!(name.as_ref(), b"DeviceRGB" | b"RGB" | b"DeviceGray" | b"G" | b"DeviceCMYK" | b"CMYK" | b"CalCMYK" | b"Pattern") {
return None;
}
self.ink_of(&res.get_color_space(name)?)
}
/// The ink pattern `name` paints in (`scn`, `SCN`): a shading pattern's (a dictionary, where a
/// tiling pattern is a stream) is its shading's.
fn pattern_ink(&mut self, name: &Name<'_>, res: &Resources<'_>) -> Option<u32> {
let Object::Dict(p) = res.get_pattern(name)? else { return None };
self.shading_ink(&p.get::<Object<'_>>(b"Shading")?)
}
/// The ink of a shading (a dictionary or a stream): its color space's.
fn shading_ink(&mut self, shading: &Object<'_>) -> Option<u32> {
let (d, _) = dict_or_stream(shading)?;
self.ink_of(&d.get::<Object<'_>>(b"ColorSpace")?)
}
/// The index in [`Scan::inks`] of color space `o` when it is a Separation or DeviceN array, its
/// colorant names noted when new.
fn ink_of(&mut self, o: &Object<'_>) -> Option<u32> {
let Object::Array(a) = o else { return None };
let (_, names) = ink_names(&mut a.flex_iter())?;
if let Some(i) = self.ink_index.get(&names) {
return Some(*i);
}
if self.out.inks.len() >= MAX_INKS {
self.inks_cut = true;
return None;
}
let i = u32::try_from(self.out.inks.len()).ok()?;
self.ink_index.insert(names.clone(), i);
self.out.inks.push(names);
Some(i)
}
fn note_fonts(&mut self, res: &Resources<'_>) {
@@ -545,9 +724,45 @@ impl Walker<'_, '_> {
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);
// Drawn when the interpreter can read the shading (the page's count tells), as a
// fill in a state of its own, in the shading's ink.
TypedInstruction::Shading(s) => {
if let Some(sh) = res.get_shading(s.0) {
let ink = self.shading_ink(&sh);
self.save();
self.set_pattern(false, ink);
self.painted(false);
self.restore();
}
}
TypedInstruction::ColorSpaceNonStroke(cs) => {
let ink = self.space_ink(cs.0, res);
self.set_space(false, ink);
}
TypedInstruction::ColorSpaceStroke(cs) => {
let ink = self.space_ink(cs.0, res);
self.set_space(true, ink);
}
// A pattern by name; without one, `scn` sets a color in the space set before.
TypedInstruction::NonStrokeColorNamed(c) => {
if let Some(name) = c.1 {
let ink = self.pattern_ink(name, res);
self.set_pattern(false, ink);
}
}
TypedInstruction::StrokeColorNamed(c) => {
if let Some(name) = c.1 {
let ink = self.pattern_ink(name, res);
self.set_pattern(true, ink);
}
}
TypedInstruction::NonStrokeColorDeviceGray(_)
| TypedInstruction::NonStrokeColorDeviceRgb(_)
| TypedInstruction::NonStrokeColorCmyk(_) => {
self.set_space(false, None);
}
TypedInstruction::StrokeColorDeviceGray(_) | TypedInstruction::StrokeColorDeviceRgb(_) | TypedInstruction::StrokeColorCmyk(_) => {
self.set_space(true, None);
}
TypedInstruction::ShowText(_)
| TypedInstruction::ShowTexts(_)
@@ -647,10 +862,17 @@ fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &
max_form_bytes,
out: Scan::default(),
hidden_form: false,
states: vec![Overprint::default()],
states: vec![State::default()],
text: TextOverprint::None,
text_inks: [RunInk::None; 2],
ink_index: HashMap::new(),
inks_cut: false,
};
w.page(page);
w.inks_cut |= !w.out.text_inks.set(w.out.paints.len(), w.text_inks.map(RunInk::ink));
if w.inks_cut {
w.out.inks.clear();
}
w.out.texts.push(w.text);
w
}
+12 -10
View File
@@ -23,15 +23,15 @@ use crate::import_mask::{contains, is_rectangle, luminance};
const MAX_PATCHES: usize = 4096;
/// An axial or radial shading as a gradient paint, with whether it extends past its start and
/// its end.
pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option<(GradientPaint, [bool; 2])> {
/// its end. `inks`: the colorant names of its space, from the page's walk ([`Colors::solid`]).
pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[String]>) -> Option<(GradientPaint, [bool; 2])> {
let ShadingType::RadialAxial { coords, domain, function, axial, extend } = sp.shading.shading_type.as_ref() else {
return None;
};
let m = sp.matrix;
let cs = &sp.shading.color_space;
let at = |t: f32| function.eval(&smallvec::smallvec![domain[0] + (domain[1] - domain[0]) * t]);
let space = colors.shading_space(cs, at(0.0)?.len());
let space = colors.shading_space(cs, at(0.0)?.len(), inks);
// RGB to decide which samples are stops; the stops keep the shading's model.
let sample = |t: f32| -> Option<Color> {
let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components();
@@ -140,12 +140,13 @@ pub(crate) fn extend_clip(sp: &ShadingPattern, bounds: Rect) -> Option<BezPath>
}
/// The colour of shading components `v` (the input of the shading's function, when it has one).
fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern, colors: &mut Colors<'_>) -> Color {
/// `inks` as for [`shading_gradient`].
fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[String]>) -> Color {
let comps = match function {
Some(f) => f.eval(&v.iter().copied().collect()).map(|c| c.to_vec()).unwrap_or_default(),
None => v.to_vec(),
};
let space = colors.shading_space(&sp.shading.color_space, comps.len());
let space = colors.shading_space(&sp.shading.color_space, comps.len(), inks);
colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| {
let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components();
Color::rgb(round3(r), round3(g), round3(b))
@@ -153,8 +154,9 @@ fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern
}
/// A patch mesh or triangle mesh shading as gradient meshes in document space (one per patch;
/// a triangle is a patch with two corners together). `None`: not a mesh, or too big.
pub(crate) fn mesh_shading(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Option<Vec<GradientMesh>> {
/// a triangle is a patch with two corners together). `None`: not a mesh, or too big. `inks` as
/// for [`shading_gradient`].
pub(crate) fn mesh_shading(sp: &ShadingPattern, colors: &mut Colors<'_>, inks: Option<&[String]>) -> Option<Vec<GradientMesh>> {
let m = sp.matrix;
let mut out = vec![];
match sp.shading.shading_type.as_ref() {
@@ -163,7 +165,7 @@ pub(crate) fn mesh_shading(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Opti
return None;
}
for p in patches {
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors));
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors, inks));
out.extend(GradientMesh::coons(&p.control_points.map(|q| m * q), colors));
}
}
@@ -173,7 +175,7 @@ pub(crate) fn mesh_shading(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Opti
return None;
}
for p in patches {
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors));
let colors = [0, 1, 2, 3].map(|i| mesh_color(p.colors.get(i).map_or(&[][..], |c| c.as_slice()), function.as_ref(), sp, colors, inks));
out.extend(p.control_points.map(|q| m * q).first_chunk::<12>().and_then(|cp| GradientMesh::coons(cp, colors)));
}
}
@@ -182,7 +184,7 @@ pub(crate) fn mesh_shading(sp: &ShadingPattern, colors: &mut Colors<'_>) -> Opti
return None;
}
for t in triangles {
let v = [&t.p0, &t.p1, &t.p2].map(|v| (m * v.point, mesh_color(v.colors.as_slice(), function.as_ref(), sp, colors)));
let v = [&t.p0, &t.p1, &t.p2].map(|v| (m * v.point, mesh_color(v.colors.as_slice(), function.as_ref(), sp, colors, inks)));
out.extend(GradientMesh::triangle(v));
}
}
+187 -5
View File
@@ -164,19 +164,201 @@ fn device_n_inks_become_spot_swatches_and_process_plates_cmyk() {
assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 2);
}
/// The spaces `/CS1` and `/CS2` of two inks (`[/{kind} {a} …]`, `[/{kind} {b} …]`) with the same
/// alternate and tint transform, blue at 100 %: they look the same to the interpreter.
fn blues(kind: &str, a: &str, b: &str) -> String {
let tint = "/DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>";
format!("/CS1 [/{kind} {a} {tint}] /CS2 [/{kind} {b} {tint}]")
}
/// The swatch each fill, stroke and type run links to, in paint order (a gradient's: its first
/// stop's).
fn links(d: &Document) -> Vec<Option<String>> {
let mut out = vec![];
d.walk(|n: &Node| {
let runs: Vec<&Paint> = match &n.kind {
NodeKind::Text(t) => t.runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke]).filter(|p| !p.is_none()).collect(),
_ => vec![],
};
for p in n.appearance.items.iter().map(|i| i.paint()).chain(runs) {
out.push(match p {
Paint::Solid { swatch, .. } => swatch.clone(),
Paint::Gradient(g) => g.gradient.stops.first().and_then(|s| s.swatch.clone()),
_ => None,
});
}
});
out
}
fn named(names: &[Option<&str>]) -> Vec<Option<String>> {
names.iter().map(|n| n.map(String::from)).collect()
}
#[test]
fn spot_inks_with_the_same_values_keep_their_own_swatches() {
// The page's walk names the ink each path is painted in: Blue1 and Blue2 fills, then a fill of
// Blue1 stroked in Blue2.
for (kind, a, b) in [("Separation", "/Blue1", "/Blue2"), ("DeviceN", "[/Blue1]", "[/Blue2]")] {
let content = "/CS1 cs 1 scn 0 0 10 10 re f /CS2 cs 0.5 scn 12 0 10 10 re f /CS2 CS 1 SCN /CS1 cs 1 scn 24 0 10 10 re B";
let page = PdfPage { resources: format!("/ColorSpace << {} >>", blues(kind, a, b)), ..PdfPage::new(200.0, 50.0, content) };
let d = import(&pdf(&[page], None)).unwrap();
assert_eq!(links(&d), named(&[Some("Blue1"), Some("Blue2"), Some("Blue1"), Some("Blue2")]), "{kind}");
assert!((solid(&fills(&d)[1]).2 - 0.5).abs() < 1e-6, "{kind}: Blue2 keeps its tint");
for name in ["Blue1", "Blue2"] {
let sw = d.swatch(name).unwrap_or_else(|| panic!("{kind}: no {name}"));
assert!(sw.spot && sw.global, "{kind}: {name} is a spot swatch");
assert!(near(sw.paint.color().unwrap(), Color::rgb(0.0, 0.0, 1.0)), "{kind}: {name}");
}
}
}
#[test]
fn inks_with_the_same_values_on_different_pages_keep_their_own_swatches() {
// Each page names its own ink `/CS0`.
let page = |ink: &str| {
let tint = "/DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>";
PdfPage {
resources: format!("/ColorSpace << /CS0 [/Separation /{ink} {tint}] >>"),
..PdfPage::new(100.0, 50.0, "/CS0 cs 1 scn 0 0 10 10 re f")
}
};
let d = import(&pdf(&[page("Blue1"), page("Blue2")], None)).unwrap();
assert_eq!(links(&d), named(&[Some("Blue1"), Some("Blue2")]));
assert_eq!(d.swatches.iter().filter(|s| s.spot).count(), 2);
}
#[test]
fn inks_set_before_a_content_window_ends_reach_the_paths_after_it() {
// The inks are set in the first 64 KiB window of the content and a path is painted in them
// two windows later, then a fill in Blue2 after the state saved before the windows is
// restored.
let filler = "q Q ".repeat(40_000);
let content = format!("/CS1 cs 1 scn /CS2 CS 1 SCN q {filler}0 0 10 10 re B Q /CS2 cs 1 scn 12 0 10 10 re f");
let page = PdfPage { resources: format!("/ColorSpace << {} >>", blues("Separation", "/Blue1", "/Blue2")), ..PdfPage::new(200.0, 50.0, &content) };
let d = import(&pdf(&[page], None)).unwrap();
assert_eq!(links(&d), named(&[Some("Blue1"), Some("Blue2"), Some("Blue2")]));
}
#[test]
fn type_in_spot_inks_with_the_same_values_keeps_its_own_ink() {
// Type filled in Blue1, a black square, type stroked in Blue2: type or its outlines, as the
// fonts of the machine allow.
let content =
"/CS1 cs 1 scn BT /F1 36 Tf 10 50 Td (Ink) Tj ET 0 g 0 0 5 5 re f /CS1 cs 1 scn /CS2 CS 1 SCN BT /F1 36 Tf 1 Tr 10 10 Td (Line) Tj ET";
let resources = format!(
"/ColorSpace << {} >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>",
blues("Separation", "/Blue1", "/Blue2")
);
let d = import(&pdf(&[PdfPage { resources, ..PdfPage::new(300.0, 100.0, content) }], None)).unwrap();
let mut seen = links(&d);
seen.dedup();
assert_eq!(seen, named(&[Some("Blue1"), None, Some("Blue2")]));
}
#[test]
fn gradients_in_spot_inks_with_the_same_values_keep_their_own_ink() {
// A shading in Blue2 drawn by `sh`, then a shading pattern in Blue1 filling a square.
let shading = |ink: &str| {
format!(
"<< /ShadingType 2 /ColorSpace [/Separation /{ink} /DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>] /Coords [0 0 50 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> /Extend [true true] >>"
)
};
let resources = format!("/Shading << /Sh0 {} >> /Pattern << /P0 << /PatternType 2 /Shading {} >> >>", shading("Blue2"), shading("Blue1"));
let content = "q 0 0 50 50 re W n /Sh0 sh Q /Pattern cs /P0 scn 60 0 50 50 re f";
let d = import(&pdf(&[PdfPage { resources, ..PdfPage::new(200.0, 50.0, content) }], None)).unwrap();
let mut stops = vec![];
d.walk(|n: &Node| {
for i in &n.appearance.items {
if let Paint::Gradient(g) = i.paint() {
stops.push(g.gradient.stops.iter().map(|s| s.swatch.clone()).collect::<Vec<_>>());
}
}
});
assert_eq!(stops.len(), 2, "{stops:?}");
assert!(stops[0].iter().all(|s| s.as_deref() == Some("Blue2")), "{stops:?}");
assert!(stops[1].iter().all(|s| s.as_deref() == Some("Blue1")), "{stops:?}");
}
#[test]
fn a_color_set_after_a_pattern_is_in_the_ink_of_its_space() {
// Blue1's space is set, `/P0 scn` names a shading pattern in Blue2, and `1 scn` sets a color
// without a pattern: the interpreter fills in Blue1, the color space's ink.
let tint = "/DeviceRGB << /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 1] /N 1 >>";
let shading = format!(
"<< /ShadingType 2 /ColorSpace [/Separation /Blue2 {tint}] /Coords [0 0 50 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >>"
);
let resources =
format!("/ColorSpace << {} >> /Pattern << /P0 << /PatternType 2 /Shading {shading} >> >>", blues("Separation", "/Blue1", "/Blue2"));
let d = import(&pdf(&[PdfPage { resources, ..PdfPage::new(100.0, 50.0, "/CS1 cs /P0 scn 1 scn 0 0 10 10 re f") }], None)).unwrap();
assert_eq!(links(&d), named(&[Some("Blue1")]));
}
#[test]
fn a_mesh_in_one_of_two_inks_with_the_same_values_keeps_the_ink() {
// A patch mesh in Plum, which looks the same to the interpreter as Wine: its points keep
// Plum's CMYK values, and Plum is a spot swatch.
let tint = "/DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0.5 1 0 0] /N 1 >>";
let data = "000A0A0A250A3F0A5A255A3F5A5A5A5A3F5A255A0A3F0A250AFFFFFFFF>";
let mesh = format!(
"<< /ShadingType 6 /ColorSpace [/Separation /Plum {tint}] /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1] /Filter /ASCIIHexDecode /Length {} >>\nstream\n{data}\nendstream",
data.len()
);
let resources = format!("/ColorSpace << /CS1 [/Separation /Wine {tint}] >> /Shading << /Sh0 {} 0 R >>", first_extra(1));
let d = import(&pdf_with(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, "/Sh0 sh") }], &[&mesh], None)).unwrap();
let mut points = vec![];
d.walk(|n: &Node| {
if let NodeKind::Mesh(m) = &n.kind {
points.extend(m.points.iter().map(|p| p.color));
}
});
assert!(!points.is_empty());
assert!(points.iter().all(|c| near(*c, Color::cmyk(0.5, 1.0, 0.0, 0.0))), "{points:?}");
assert!(d.swatch("Plum").is_some_and(|s| s.spot));
}
#[test]
fn inks_that_cannot_be_told_apart_import_as_rgb() {
// Two spaces of other inks with the same alternate and tint transform.
// Two spaces of other inks with the same alternate and tint transform, on a page with a
// shading the interpreter can't read: it draws nothing for it, so the page's walk counts one
// path more than the interpreter draws, and its ink names are left out.
let tint = "/DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 0 0] /N 1 >>";
let spaces = format!("/CS0 [/Separation /Tealish {tint}] /CS1 [/Separation /Rubyish {tint}] /CS2 [/Separation /Tealish {tint}]");
let d = import(&pdf(&[page_with(&spaces, &[("CS0", "1")])], None)).unwrap();
assert!(matches!(solid(&fills(&d)[0]), (Color::Rgb { .. }, None, _)));
let page = PdfPage {
resources: format!("/ColorSpace << {spaces} >> /Shading << /Sh0 << /ShadingType 99 >> >>"),
..PdfPage::new(200.0, 50.0, "/Sh0 sh /CS0 cs 1 scn 0 0 10 10 re f /CS1 cs 1 scn 12 0 10 10 re f")
};
let d = import(&pdf(&[page], None)).unwrap();
assert_eq!(fills(&d).len(), 2);
assert!(fills(&d).iter().all(|p| matches!(solid(p), (Color::Rgb { .. }, None, _))), "{:?}", fills(&d));
assert!(d.swatches.iter().all(|s| !s.spot));
// The same ink defined twice is still that ink.
let spaces = format!("/CS0 [/Separation /Tealish {tint}] /CS2 [/Separation /Tealish {tint}]");
let d = import(&pdf(&[page_with(&spaces, &[("CS2", "0.5")])], None)).unwrap();
assert_eq!(solid(&fills(&d)[0]).1, Some("Tealish"));
}
#[test]
fn ai_files_read_through_their_pdf_part_keep_inks_with_the_same_values_apart() {
// A page with an editor's private data, read as a `.ai` is.
let mut page = page_with(&blues("Separation", "/Blue1", "/Blue2"), &[("CS1", "1"), ("CS2", "1")]);
page.entries = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 7 /NumBlock 1 >> >> >>".into();
let r = import_with_report(&pdf(&[page], None), &ImportOptions { uncalibrated_rgb: true, ..Default::default() }).unwrap();
assert_eq!(links(&r.document), named(&[Some("Blue1"), Some("Blue2")]));
}
#[test]
fn inks_with_the_same_values_export_as_separations_of_their_own() {
let page = page_with(&blues("Separation", "/Blue1", "/Blue2"), &[("CS1", "1"), ("CS2", "0.4")]);
let d = import(&pdf(&[page], None)).unwrap();
let out = export(&d, &PdfOptions::uncompressed()).unwrap();
let text = String::from_utf8_lossy(&out);
assert!(text.contains("/Separation/Blue1") && text.contains("/Separation/Blue2"), "each ink is a separation");
let back = import(&out).unwrap();
assert_eq!(links(&back), named(&[Some("Blue1"), Some("Blue2")]));
assert!((solid(&fills(&back)[1]).2 - 0.4).abs() <= Q);
}
#[test]
fn ink_names_never_clash_with_the_document_swatches() {
let taken = Document::new(1.0, 1.0).swatches.iter().find(|s| !s.paint.is_none() && s.name != REGISTRATION).unwrap().name.clone();
@@ -208,7 +390,7 @@ fn colour_spaces_are_read_from_the_interpreters_debug_form() {
("ColorSpace(Separation(Separation { tint_transform: Function(Type2(..)) }))", 1, Space::Other),
("ColorSpace(DeviceCmyk)", 3, Space::Other),
] {
assert_eq!(c.space_of(text, n), want, "{text}");
assert_eq!(c.space_of(text, n, None), want, "{text}");
}
let t = "Color { color_space: ColorSpace(DeviceCmyk), components: [0.101960786, 0.2, 0.3, 0.4], opacity: 1.0 }";
let (space, comps) = crate::import_color::split_color(t).unwrap();
@@ -229,7 +411,7 @@ fn inks_are_found_past_dictionary_values_the_reader_cannot_parse() {
// Opened by the reader alone, without the checks of `pages::open`.
let pdf = craft_pdf::syntax::Pdf::new(pdf(&[page], None)).unwrap();
let mut c = Colors::new(&pdf, vec![]);
let space = c.space_of(&format!("ColorSpace(Separation(Separation {{ tint_transform: {key} }}))"), 1);
let space = c.space_of(&format!("ColorSpace(Separation(Separation {{ tint_transform: {key} }}))"), 1, None);
assert_eq!(space, Space::Inks(0), "{junk}");
let gold = c.native(space, &[1.0]).unwrap();
assert_eq!(gold.link, Some(("Gold".to_string(), 1.0)), "{junk}");
+1
View File
@@ -537,6 +537,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
The font database looks up a name that is neither a loaded nor an installed family among the other names of the loaded and installed fonts once for each style it is asked in, and keeps the answer until fonts are added or rescanned. Laying out a story of 1,000 paragraphs set in a font that is not installed, with 400 more fonts loaded, takes a median of 56 to 71 ms instead of 409 to 507 ms on Windows 11 (`CRAFT_FONTS_DIR=<craft-fonts checkout> cargo test --release -p vectorcraft-text --lib missing_font_story_layout_timing -- --ignored --nocapture`).
- **Files (M4):**
- PDF export and print composition use the optional shared guarded writer ([pdf-backend.md](pdf-backend.md)); app drawing and output policy and desktop printer submission remain in their existing layers. The shared reader keeps the app-owned import adapter; synthetic tests cover layer and overprint state across windows, long operands, inline images, failed decoding, document limits and incomplete tails. Empty and populated metadata key presence is regression-tested, and CI checks the immutable backend source.
A PDF, or a `.ai` read through its PDF part, whose spot inks have the same values but different names opens with a spot swatch for each ink. The solid fills and strokes of paths and type, and the stops of gradients on paths, link to the swatch of the ink they are painted in, the points of a gradient mesh in one of these inks keep the ink's color, and PDF export writes each of these inks as its own separation (`pdf/src/tests_import_color.rs`). Paint in these inks inside pattern cells, soft masks and type 3 glyphs, and gradients on type in these inks, still import as RGB. Type in one of these inks can also import as RGB when type in another Separation or DeviceN ink is shown with no path drawn between them.
- `.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). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620). A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604).
- 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 and the images in an `.ai` or EPS file's editing data, an alpha channel coming in as a grayscale opacity mask) 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.
SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `<style>` rules for its id, its classes and its element name, and the rules whose selector ends in none of these (`*`, `[x]`).