Merge pull request #98 from storytold/pr/m4-pdf-import-fidelity

M4.61–M4.62: PDF import fidelity and layers
This commit is contained in:
BFlat 2026-10-05 13:17:00 +07:00 committed by GitHub
commit 522f05c540
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 2436 additions and 178 deletions

View File

@ -887,6 +887,14 @@ fn default_graphic_styles() -> Vec<GraphicStyle> {
.collect()
}
impl Document {
/// Make the next id allocated at least `next`: ids handed out outside the layer tree (such
/// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see.
pub fn reserve_ids(&mut self, next: u64) {
self.next_id = self.next_id.max(next);
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document",
[],
None,
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard and layer each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK) → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
always,
load::open
),
@ -655,6 +655,8 @@ mod tests_pdfcolor;
#[cfg(test)]
mod tests_pdfedit;
#[cfg(test)]
mod tests_pdffidelity;
#[cfg(test)]
mod tests_pdfimport;
#[cfg(test)]
mod tests_svg;

View File

@ -4,14 +4,14 @@
use serde_json::{Value, json};
use vectorcraft_doc::{ColorMode, Document};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError};
use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs};
use super::super::*;
use super::{err, source};
/// The `document.open` options besides the file. The PDF ones (pages, box, password) are ignored
/// by other formats.
#[derive(Clone, Debug, Default, PartialEq)]
/// The `document.open` options besides the file. The PDF ones (pages, box, password, text,
/// layers) are ignored by other formats.
#[derive(Clone, Debug, PartialEq)]
pub struct LoadOptions {
/// 1-based pages to import, such as `"2-3, 5"`; `None` = every page.
pub pages: Option<String>,
@ -22,11 +22,23 @@ pub struct LoadOptions {
/// The colour mode the document opens in, its colours converted as Document Color Mode does
/// (default: the file's; a PDF painted mostly in CMYK opens in CMYK).
pub color_mode: Option<ColorMode>,
/// What a PDF's text becomes.
pub text_as: TextAs,
/// A PDF's optional content groups become layers (else one layer per page, without the art
/// that is off).
pub layers: bool,
}
impl Default for LoadOptions {
fn default() -> Self {
Self { pages: None, crop: CropTo::default(), password: None, color_mode: None, text_as: TextAs::default(), layers: true }
}
}
impl LoadOptions {
/// From command params: `pages` (`"2-3, 5"`, a page number or `"all"`), `page` (one page, when
/// `pages` isn't given), `cropTo` (or `crop`), `password` and `colorMode` (`rgb` | `cmyk`).
/// `pages` isn't given), `cropTo` (or `crop`), `password`, `colorMode` (`rgb` | `cmyk`),
/// `textAs` (`text` | `outlines`) and `layers` (true | false).
pub fn from_params(cmd: &str, p: &Value) -> Result<Self> {
let pages = match p.get("pages").filter(|v| !v.is_null()).or_else(|| p.get("page").filter(|v| !v.is_null())) {
None => None,
@ -35,15 +47,7 @@ impl LoadOptions {
Some(Value::Number(n)) => Some(n.to_string()),
Some(v) => return Err(bad(cmd, format!("pages must be a range such as \"2-3\" or a page number, not {v}"))),
};
let crop = match str_param(p, "cropTo").or_else(|| str_param(p, "crop")) {
None => CropTo::default(),
Some(c) => CropTo::ALL
.iter()
.zip(CropTo::IDS)
.find(|(_, id)| id.eq_ignore_ascii_case(c))
.map(|(c, _)| *c)
.ok_or_else(|| bad(cmd, format!("cropTo must be one of {}", CropTo::IDS.join(", "))))?,
};
let crop = choice(cmd, "cropTo", str_param(p, "cropTo").or_else(|| str_param(p, "crop")))?;
let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string);
let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() {
None => None,
@ -51,13 +55,19 @@ impl LoadOptions {
Some("cmyk") => Some(ColorMode::Cmyk),
Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))),
};
Ok(Self { pages, crop, password, color_mode })
let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?;
let layers = match p.get("layers").filter(|v| !v.is_null()) {
None => true,
Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?,
};
Ok(Self { pages, crop, password, color_mode, text_as, layers })
}
/// Does the document read only part of the file, or read it differently (a page range, another
/// box, a password)? Writing it back would lose the rest, so Save doesn't (it asks for a name).
/// box, a password, without its hidden layers)? Writing it back would lose the rest, so Save
/// doesn't (it asks for a name).
pub fn is_partial(&self) -> bool {
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some()
self.pages.is_some() || self.crop != CropTo::default() || self.password.is_some() || !self.layers
}
/// The PDF import options: `pages` resolved against the file's page count.
@ -69,20 +79,45 @@ impl LoadOptions {
Some(parse_range(range, count).map_err(|e| err(format!("pages: {e}")))?)
}
};
Ok(ImportOptions { pages, crop: self.crop, password: self.password.clone(), ..Default::default() })
Ok(ImportOptions {
pages,
crop: self.crop,
password: self.password.clone(),
text_as: self.text_as,
layers: self.layers,
..Default::default()
})
}
}
/// The option of `T` named `id` (any case), for param `key`; not given: the default.
fn choice<T: Choice + Default>(cmd: &str, key: &str, id: Option<&str>) -> Result<T> {
let Some(id) = id else { return Ok(T::default()) };
T::ALL
.iter()
.zip(T::IDS)
.find(|(_, i)| i.eq_ignore_ascii_case(id))
.map(|(t, _)| *t)
.ok_or_else(|| bad(cmd, format!("{key} must be one of {}", T::IDS.join(", "))))
}
/// Import the pages `o` pick → the document and import notes.
pub(super) fn import(bytes: &[u8], o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let r = vectorcraft_pdf::import_with_report(bytes, &o.import_options(bytes)?).map_err(err)?;
Ok((r.document, r.warnings))
}
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and the
/// import notes.
/// Page `page` (0-based) of a PDF as a document of one artboard (its `o.crop` box) and one
/// layer of what it shows (text as outlines: it looks as printed), and the import notes.
pub fn page_document(bytes: &[u8], page: usize, o: &LoadOptions) -> Result<(Document, Vec<String>)> {
let opts = ImportOptions { pages: Some(vec![page]), crop: o.crop, password: o.password.clone(), ..Default::default() };
let opts = ImportOptions {
pages: Some(vec![page]),
crop: o.crop,
password: o.password.clone(),
text_as: TextAs::Outlines,
layers: false,
..Default::default()
};
vectorcraft_pdf::import_with_report(bytes, &opts).map(|r| (r.document, r.warnings)).map_err(err)
}

View File

@ -0,0 +1,69 @@
//! `document.open` PDF options `textAs` and `layers`.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_pdf::TextAs;
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf, pdf_with_catalog};
use super::*;
fn open(s: &mut Session, bytes: &[u8], extra: Value) -> crate::Result<Value> {
let mut p = json!({"name": "art.pdf", "dataBase64": vectorcraft_format::base64_encode(bytes)});
if let (Some(o), Value::Object(e)) = (p.as_object_mut(), extra) {
o.extend(e);
}
s.execute("document.open", &p)
}
fn kinds(s: &Session) -> Vec<&'static str> {
let mut out = vec![];
s.active().unwrap().doc.walk(|n| out.push(n.kind_label()));
out
}
#[test]
fn text_opens_as_type_or_outlines() {
let page = PdfPage {
resources: "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>".into(),
..PdfPage::new(200.0, 100.0, "BT /F1 12 Tf 10 50 Td (Hello) Tj ET")
};
let bytes = pdf(&[page], None);
let mut s = Session::new();
open(&mut s, &bytes, json!({})).unwrap();
assert!(kinds(&s).contains(&"Type"), "{:?}", kinds(&s));
let r = open(&mut s, &bytes, json!({"textAs": "outlines"})).unwrap();
assert!(!kinds(&s).contains(&"Type"));
assert!(r["warnings"].to_string().contains("outlines"), "{r}");
let e = open(&mut s, &bytes, json!({"textAs": "glyphs"})).unwrap_err().to_string();
assert!(e.contains("textAs must be one of text, outlines"), "{e}");
}
#[test]
fn layers_option_keeps_or_flattens_optional_content() {
let (a, b) = (first_extra(1), first_extra(1) + 1);
let catalog = format!("/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /OFF [{b} 0 R] >> >> ");
let page = PdfPage {
resources: format!("/Properties << /A {a} 0 R /B {b} 0 R >>"),
..PdfPage::new(100.0, 100.0, "/OC /A BDC 0 g 0 0 10 10 re f EMC /OC /B BDC 0 g 20 20 10 10 re f EMC")
};
let bytes = pdf_with_catalog(&[page], &["<< /Type /OCG /Name (Ink) >>", "<< /Type /OCG /Name (Notes) >>"], &catalog, None);
let mut s = Session::new();
open(&mut s, &bytes, json!({})).unwrap();
let layers: Vec<(String, bool)> = s.active().unwrap().doc.layers.iter().map(|l| (l.name.clone().unwrap_or_default(), l.visible)).collect();
assert_eq!(layers, [("Ink".to_string(), true), ("Notes".to_string(), false)]);
open(&mut s, &bytes, json!({"layers": false})).unwrap();
let doc = &s.active().unwrap().doc;
assert_eq!(doc.layers.len(), 1);
assert_eq!(doc.layers[0].children().unwrap().len(), 1, "the hidden art is left out");
assert!(matches!(doc.layers[0].kind, NodeKind::Layer { .. }));
assert!(open(&mut s, &bytes, json!({"layers": "no"})).unwrap_err().to_string().contains("layers must be true or false"));
}
#[test]
fn load_options_read_text_and_layers() {
let o = LoadOptions::from_params("x", &json!({"textAs": "Outlines", "layers": false})).unwrap();
assert_eq!((o.text_as, o.layers), (TextAs::Outlines, false));
assert!(o.is_partial(), "without its hidden layers, Save doesn't write the file back");
let d = LoadOptions::default();
assert_eq!((d.text_as, d.layers, d.is_partial()), (TextAs::Text, true, false));
}

View File

@ -435,6 +435,87 @@ proptest! {
}
}
/// Hostile content over layers (optional content, one group off), soft masks, tiling patterns,
/// a patch mesh, an unextended shading and fonts: the import's own readings of the file.
fn rich_resources_pdf(content: &str, config: &str) -> Vec<u8> {
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
let x = first_extra(1);
let stream = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len());
let extra = [
"<< /Type /OCG /Name (A) >>".to_string(),
"<< /Type /OCG /Name <FEFF> /Usage << /Print << /PrintState /OFF >> >> >>".to_string(),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB /I true /K true >>",
"1 g 0 0 50 50 re f /OC /MC0 BDC 0 g 0 0 5 5 re f EMC",
),
stream(
"/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 1e-9 10] /XStep 0.001 /YStep 10 /Resources << >>",
"0 0 5 5 re f /P0 scn 0 0 1 1 re f",
),
stream(
"/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
"000A0A0A250A3F0A5A255A3F5A5A5A5A3F5A255A0A3F0A250AFF000000FF000000FFFFFFFF>",
),
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC 6 0 R", "/X0 Do"),
];
let (a, b, form, pat, mesh, hidden) = (x, x + 1, x + 2, x + 3, x + 4, x + 5);
let resources = format!(
"/Properties << /MC0 {a} 0 R /MC1 {b} 0 R >> /XObject << /X0 {form} 0 R /X1 {hidden} 0 R >> /Pattern << /P0 {pat} 0 R >> /Shading << /Sh0 {mesh} 0 R /Sh1 << /ShadingType 3 /ColorSpace /DeviceGray /Coords [50 50 0 50 50 40] /Extend [false false] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> >> >> /ExtGState << /GS0 << /SMask << /Type /Mask /S /Luminosity /G {form} 0 R /BC [1] /TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >> >> >> /GS1 << /SMask << /Type /Mask /S /Alpha /G {form} 0 R >> >> >> /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Odd-Name,Bold >> >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"
);
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();
pdf_with_catalog(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &extra, &catalog, None)
}
fn arb_rich_op() -> impl Strategy<Value = String> {
prop_oneof![
arb_pdf_op(),
prop::sample::select(vec![
"/OC /MC0 BDC",
"/OC /MC1 BDC",
"/OC /Nope BDC",
"/Span << /MCID 3 >> BDC",
"/X BMC",
"EMC",
"/X0 Do",
"/X1 Do",
"/GS0 gs",
"/GS1 gs",
"/CS0 cs 1 0 0 /P0 scn",
"/Pattern cs /P0 scn",
"/Sh0 sh",
"/Sh1 sh",
"BT /F1 12 Tf 10 10 Td (Hi there) Tj [(a) -900 (b)] TJ ET",
"7 Tr",
"q 10 10 50 50 re W n",
"Q",
"0 0 100 100 re f",
"10 10 20 20 re B",
])
.prop_map(String::from),
]
}
proptest! {
#![proptest_config(config())]
#[test]
fn pdf_layers_masks_patterns_and_text_never_panic(
ops in prop::collection::vec(arb_rich_op(), 0..40),
config in prop::sample::select(vec!["/OFF [{B}]", "/BaseState /OFF", "/BaseState /Unchanged /ON [{B}] /Locked [{B}]", "", "/OFF {B}"]),
layers in any::<bool>(),
outlines in any::<bool>(),
) {
let bytes = rich_resources_pdf(&ops.join("\n"), config);
let opts = vectorcraft_pdf::ImportOptions {
layers,
text_as: if outlines { vectorcraft_pdf::TextAs::Outlines } else { vectorcraft_pdf::TextAs::Text },
..Default::default()
};
survive(&ops.join(" "), || vectorcraft_pdf::import_with_report(&bytes, &opts).ok().map(|r| r.document))?;
}
}
// ---------- library files ----------
/// The `data` of a library file `cmd` writes for the rich document.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,128 @@
//! Soft masks → opacity masks. A luminosity mask's art becomes the mask art; an alpha mask's art
//! is turned white (its opacity kept), so its luminance over the black backdrop is its alpha.
//! The backdrop colour gives Clip (black: outside the art is hidden), an inverting transfer
//! function gives Invert, and the rectangles the PDF export draws for those (a backdrop cover, a
//! white Difference cover) read back as the options rather than as art. An alpha mask that is one
//! rectangle of constant opacity over everything is plain opacity.
use std::sync::Arc;
use kurbo::{Rect, Shape};
use vectorcraft_color::blend::MASK_LUM;
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{AppearanceItem, Node, NodeKind, OpacityMask};
/// What a soft mask does to what it masks.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum MaskSpec {
/// A constant opacity (a group drawn through it is not isolated).
Opacity(f32),
Mask(OpacityMask),
}
/// The luminance of `c` (0 black … 1 white), as opacity masks weigh it.
pub(crate) fn luminance(c: &Color) -> f32 {
let rgb = c.to_rgb();
rgb.iter().zip(MASK_LUM).map(|(v, w)| v * w).sum()
}
/// A rectangle (at most 6 path elements, its area that of its bounds).
pub(crate) fn is_rectangle(p: &kurbo::BezPath) -> bool {
let b = p.bounding_box();
p.elements().len() <= 6 && b.area() > 0.0 && (p.area().abs() - b.area()).abs() <= b.area() * 1e-3
}
/// A path covering `page` filled with one solid colour and no stroke → its colour and opacity.
fn cover(n: &Node, page: Rect) -> Option<(Color, f32)> {
let NodeKind::Path { path, .. } = &n.kind else { return None };
let [AppearanceItem::Fill(f)] = n.appearance.items.as_slice() else { return None };
let Paint::Solid { color, .. } = &f.paint else { return None };
let b = path.to_bezpath();
let covers = page.area() > 1.0 && contains(b.bounding_box(), page.inset(-0.5)) && is_rectangle(&b);
(covers && n.mask.is_none()).then_some((*color, f.opacity * n.opacity))
}
/// Does `outer` contain `inner`?
pub(crate) fn contains(outer: Rect, inner: Rect) -> bool {
outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1
}
/// Every paint of `n` (and its children) white, opacities kept: an alpha mask's art as a
/// luminosity mask's.
fn whiten(n: &mut Node) {
let white = |p: &mut Paint| match p {
Paint::Solid { color, swatch, tint } => {
(*color, *swatch, *tint) = (Color::WHITE, None, 1.0);
}
Paint::Gradient(g) => {
for s in &mut g.gradient.stops {
(s.color, s.swatch, s.tint) = (Color::WHITE, None, 1.0);
}
}
Paint::None | Paint::Pattern { .. } => {}
};
for item in &mut n.appearance.items {
white(item.paint_mut());
}
match &mut n.kind {
NodeKind::Mesh(m) => m.points.iter_mut().for_each(|p| p.color = Color::WHITE),
NodeKind::Text(t) => t.runs.iter_mut().for_each(|r| {
white(&mut r.style.fill);
white(&mut r.style.stroke);
}),
_ => {}
}
if let Some(ch) = n.children_mut() {
for c in ch {
whiten(Arc::make_mut(c));
}
}
}
/// How a soft mask drawing `art` acts: `alpha` for an alpha mask, `backdrop` the luminance outside
/// the art, `inverted` when its transfer function inverts it. `page`: the area masks cover.
pub(crate) fn mask_spec(
mut art: Vec<Arc<Node>>,
alpha: bool,
backdrop: f32,
inverted: bool,
page: Rect,
group: impl FnOnce(Vec<Arc<Node>>) -> Node,
) -> MaskSpec {
if alpha {
if let [only] = art.as_slice()
&& let Some((_, a)) = cover(only, page)
{
return MaskSpec::Opacity(a);
}
art.iter_mut().for_each(|n| whiten(Arc::make_mut(n)));
}
let mut clip = alpha || backdrop < 0.5;
let mut invert = inverted;
// The PDF export's covers: a white Difference rectangle on top inverts, an opaque one at the
// bottom is the backdrop (white: no clip).
if !alpha {
if art.len() > 1
&& let Some(last) = art.last()
&& last.blend == BlendMode::Difference
&& cover(last, page).is_some_and(|(c, a)| a >= 0.999 && luminance(&c) > 0.999)
{
art.pop();
invert = !invert;
}
if art.len() > 1
&& let Some(first) = art.first()
&& first.blend == BlendMode::Normal
&& let Some((c, a)) = cover(first, page)
&& a >= 0.999
{
clip = luminance(&c) < 0.5;
art.remove(0);
}
}
let art = match <[Arc<Node>; 1]>::try_from(art) {
Ok([one]) => Arc::unwrap_or_clone(one),
Err(all) => group(all),
};
MaskSpec::Mask(OpacityMask { invert, ..OpacityMask::new(art, clip) })
}

View File

@ -0,0 +1,331 @@
//! What the interpreter doesn't tell the import, read from the file itself: the optional content
//! groups (layers) with their states, which group each marked-content sequence belongs to, the
//! isolate and knockout flags of transparency groups, and the names of the fonts.
//!
//! hayro-interpret reports a marked-content sequence by its tag alone and a transparency group
//! without its flags, and skips the content of groups that are off. So each page's content is
//! walked here in the interpreter's order (page content, then form XObjects as they are drawn,
//! skipping what it skips): the k-th `BDC`/`BMC` it reports is the k-th one found here (its tag
//! checks that), and the k-th form transparency group is the k-th group found here. To import
//! content that is off as hidden layers, [`all_on`] gives the file an update that turns every
//! group on.
use std::collections::{HashMap, HashSet};
use hayro_interpret::CacheKey;
use hayro_syntax::Pdf;
use hayro_syntax::content::TypedIter;
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Array, Dict, FromBytes, Name, Object, ObjectIdentifier, dict_or_stream};
use hayro_syntax::page::{Page, Resources};
/// The interpreter's limit on nested form XObjects.
const MAX_DEPTH: u32 = 50;
/// The most content operators walked per page: past it the rest of the page isn't told apart.
const MAX_OPS: usize = 2_000_000;
/// The most groups read.
const MAX_GROUPS: usize = 10_000;
/// An optional content group: a layer.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Ocg {
pub name: String,
/// On in the file's default configuration (shown by viewers).
pub on: bool,
/// Printed (its print usage isn't off).
pub print: bool,
pub locked: bool,
}
/// The file's optional content groups.
#[derive(Default)]
pub(crate) struct Ocgs {
pub list: Vec<Ocg>,
index: HashMap<ObjectIdentifier, usize>,
/// The groups that are off (as the interpreter decides it).
off: HashSet<ObjectIdentifier>,
}
impl Ocgs {
/// The groups listed in the catalog's `/OCProperties`, with their default states.
pub fn read(pdf: &Pdf) -> Self {
let mut o = Self::default();
let xref = pdf.xref();
let Some(props) = xref.get::<Dict<'_>>(xref.root_id()).and_then(|c| c.get::<Dict<'_>>(b"OCProperties")) else {
return o;
};
let config = props.get::<Dict<'_>>(b"D").unwrap_or_default();
let refs = |d: &Dict<'_>, key: &[u8]| -> Vec<ObjectIdentifier> {
d.get::<Array<'_>>(key)
.map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).take(MAX_GROUPS).collect())
.unwrap_or_default()
};
let all = refs(&props, b"OCGs");
if config.get::<Name<'_>>(b"BaseState").is_some_and(|n| n.as_ref() == b"OFF") {
o.off.extend(all.iter().copied());
}
for id in refs(&config, b"ON") {
o.off.remove(&id);
}
o.off.extend(refs(&config, b"OFF"));
let locked: HashSet<_> = refs(&config, b"Locked").into_iter().collect();
for id in all {
if let Some(d) = xref.get::<Dict<'_>>(id) {
o.add(id, &d, locked.contains(&id));
}
}
o
}
/// Note group `id` (its dictionary `d`) → its index.
fn add(&mut self, id: ObjectIdentifier, d: &Dict<'_>, locked: bool) -> usize {
if let Some(i) = self.index.get(&id) {
return *i;
}
let name = d.get::<hayro_syntax::object::String<'_>>(b"Name").map(|s| text_string(s.as_bytes())).unwrap_or_default();
let print = d
.get::<Dict<'_>>(b"Usage")
.and_then(|u| u.get::<Dict<'_>>(b"Print"))
.and_then(|p| p.get::<Name<'_>>(b"PrintState"))
.is_none_or(|s| s.as_ref() != b"OFF");
let on = !self.off.contains(&id);
self.list.push(Ocg { name: if name.is_empty() { format!("Layer {}", self.list.len() + 1) } else { name }, on, print, locked });
self.index.insert(id, self.list.len() - 1);
self.list.len() - 1
}
/// Is any group off?
pub fn any_off(&self) -> bool {
self.list.iter().any(|g| !g.on)
}
/// The group content marked with `/OC` (`d`: the group or membership dictionary, `id` its
/// object) belongs to: a membership dictionary's first group.
fn group_of(&mut self, d: &Dict<'_>, id: ObjectIdentifier, xref: &hayro_syntax::xref::XRef) -> Option<usize> {
if d.get::<Name<'_>>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD") {
let first = d.get::<Array<'_>>(b"OCGs").and_then(|a| a.raw_iter().find_map(|i| i.as_obj_ref())).or_else(|| d.get_ref(b"OCGs"))?;
let id = ObjectIdentifier::from(first);
return self.note(id, &xref.get::<Dict<'_>>(id)?);
}
self.note(id, d)
}
/// The index of group `id` (dictionary `d`), noted if new (up to [`MAX_GROUPS`]).
fn note(&mut self, id: ObjectIdentifier, d: &Dict<'_>) -> Option<usize> {
match self.index.get(&id) {
Some(i) => Some(*i),
None => (self.list.len() < MAX_GROUPS).then(|| self.add(id, d, false)),
}
}
}
/// A PDF text string: UTF-16 (with its byte order mark), UTF-8 (with one) or PDFDocEncoding
/// (read as Latin-1, which it matches for letters).
pub(crate) fn text_string(b: &[u8]) -> String {
if let Some(rest) = b.strip_prefix(&[0xFE, 0xFF]) {
let units: Vec<u16> = rest.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes(*c)).collect();
return String::from_utf16_lossy(&units);
}
if let Some(rest) = b.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return String::from_utf8_lossy(rest).into_owned();
}
b.iter().map(|&c| c as char).collect()
}
/// One page as the interpreter walks it.
#[derive(Default)]
pub(crate) struct Scan {
/// Each marked-content sequence: its tag and the group (index into [`Ocgs::list`]) it marks.
pub tags: Vec<(Vec<u8>, Option<usize>)>,
/// Each form transparency group: isolated, knockout.
pub groups: Vec<(bool, bool)>,
}
struct Walker<'w, 'p> {
ocgs: &'w mut Ocgs,
/// The groups that are off for the interpreter (none once [`all_on`] turned them on).
off: HashSet<ObjectIdentifier>,
xref: &'p hayro_syntax::xref::XRef,
fonts: &'w mut HashMap<u128, String>,
visible: Vec<bool>,
ops: usize,
out: Scan,
/// A form XObject hidden by its own optional content was met.
hidden_form: bool,
}
impl Walker<'_, '_> {
fn is_visible(&self) -> bool {
self.visible.last().copied().unwrap_or(true)
}
fn begin(&mut self, on: bool) {
let v = self.is_visible() && on;
self.visible.push(v);
}
/// Enter optional content `d` (object `id`): a group, or a membership dictionary whose policy
/// decides from its groups.
fn begin_oc(&mut self, d: &Dict<'_>, id: Option<ObjectIdentifier>) {
let membership = id.is_none() || d.get::<Name<'_>>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD");
let on = match id {
Some(id) if !membership => !self.off.contains(&id),
_ => {
let ids: Vec<ObjectIdentifier> = match d.get::<Array<'_>>(b"OCGs") {
Some(a) => a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect(),
None => d.get_ref(b"OCGs").map(ObjectIdentifier::from).into_iter().collect(),
};
let on = |i: &ObjectIdentifier| !self.off.contains(i);
ids.is_empty()
|| match d.get::<Name<'_>>(b"P").as_deref() {
Some(b"AllOn") => ids.iter().all(on),
Some(b"AnyOff") => !ids.iter().all(on),
Some(b"AllOff") => !ids.iter().any(on),
_ => ids.iter().any(on),
}
}
};
self.begin(on);
}
fn note_fonts(&mut self, res: &Resources<'_>) {
for (key, _) in res.fonts.entries() {
if let Some(f) = res.fonts.get::<Dict<'_>>(key.as_ref())
&& let Some(name) = f.get::<Name<'_>>(b"BaseFont")
{
self.fonts.entry(f.cache_key()).or_insert_with(|| String::from_utf8_lossy(name.as_ref()).into_owned());
}
}
}
fn walk(&mut self, mut iter: TypedIter<'_>, res: &Resources<'_>, depth: u32) {
self.note_fonts(res);
while let Some(op) = iter.next() {
self.ops += 1;
if self.ops > MAX_OPS {
return;
}
match op {
TypedInstruction::BeginMarkedContentWithProperties(bdc) => {
let oc = match bdc.1 {
Object::Name(n) => {
res.properties.get_ref(n.as_ref()).map(|r| (res.properties.get::<Dict<'_>>(n.as_ref()).unwrap_or_default(), r))
}
o => dict_or_stream(o)
.and_then(|(props, _)| props.get_ref(b"OC").map(|r| (props.get::<Dict<'_>>(b"OC").unwrap_or_default(), r))),
};
let group = match oc {
Some((d, r)) => {
let id = ObjectIdentifier::from(r);
self.begin_oc(&d, Some(id));
self.ocgs.group_of(&d, id, self.xref)
}
None => {
self.begin(true);
None
}
};
self.out.tags.push((bdc.0.to_vec(), group));
}
TypedInstruction::BeginMarkedContent(bmc) => {
self.begin(true);
self.out.tags.push((bmc.0.to_vec(), None));
}
TypedInstruction::EndMarkedContent(_) => {
self.visible.pop();
}
TypedInstruction::XObject(x) => {
if !self.is_visible() || depth >= MAX_DEPTH {
continue;
}
let Some(s) = res.get_x_object(x.0) else { continue };
let d = s.dict();
if d.get::<Name<'_>>(b"Subtype").as_deref() != Some(b"Form") || d.get::<[f32; 4]>(b"BBox").is_none() {
continue;
}
let Ok(data) = s.decoded() else { continue };
let oc = d.get::<Dict<'_>>(b"OC");
if let Some(oc) = &oc {
self.begin_oc(oc, d.get_ref(b"OC").map(ObjectIdentifier::from));
self.hidden_form |= !self.is_visible();
}
if self.is_visible() {
if let Some(g) = d.get::<Dict<'_>>(b"Group")
&& self.out.groups.len() < MAX_GROUPS
{
self.out.groups.push((g.get::<bool>(b"I").unwrap_or(false), g.get::<bool>(b"K").unwrap_or(false)));
}
let inner = Resources::from_parent(d.get::<Dict<'_>>(b"Resources").unwrap_or_default(), res.clone());
self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1);
}
if oc.is_some() {
self.visible.pop();
}
}
_ => {}
}
}
}
}
/// Walk `page` as the interpreter will: `all_on` when [`all_on`] turned every group on. Fonts
/// found are noted in `fonts` (their cache key → base font name).
pub(crate) fn scan_page(page: &Page<'_>, ocgs: &mut Ocgs, all_on: bool, fonts: &mut HashMap<u128, String>) -> Scan {
walk_page(page, ocgs, all_on, fonts).out
}
fn walk_page<'w, 'p>(page: &Page<'p>, ocgs: &'w mut Ocgs, all_on: bool, fonts: &'w mut HashMap<u128, String>) -> Walker<'w, 'p> {
let off = if all_on { HashSet::new() } else { ocgs.off.clone() };
let mut w = Walker { ocgs, off, xref: page.xref(), fonts, visible: vec![], ops: 0, out: Scan::default(), hidden_form: false };
w.walk(page.typed_operations(), page.resources(), 0);
w
}
/// Does `page` draw a form XObject that is off by its own optional content (not a marked-content
/// sequence)? Its art couldn't be told apart by group once every group is on.
pub(crate) fn hides_forms(page: &Page<'_>, ocgs: &mut Ocgs) -> bool {
walk_page(page, ocgs, false, &mut HashMap::new()).hidden_form
}
/// `bytes` with an update appended that drops the catalog's `/OCProperties`, so every group
/// draws; `None` when the file's structure can't take one.
pub(crate) fn all_on(bytes: &[u8], pdf: &Pdf) -> Option<Vec<u8>> {
let root = pdf.xref().root_id();
let catalog = pdf.xref().get::<Dict<'_>>(root)?;
let body = catalog.data().strip_suffix(b">>")?;
let prev = last_xref(bytes)?;
let trailer = trailer_at(bytes, prev)?;
let inner = trailer.strip_prefix(b"<<")?.strip_suffix(b">>")?;
let mut out = bytes.to_vec();
if !out.ends_with(b"\n") {
out.push(b'\n');
}
let at = out.len();
out.extend(format!("{} {} obj\n", root.obj_number, root.gen_number).bytes());
out.extend_from_slice(body);
// A later key wins: the groups' configuration is gone, so the interpreter draws them all.
out.extend_from_slice(b" /OCProperties null >>\nendobj\n");
let xref = out.len();
out.extend(format!("xref\n{} 1\n{at:010} {:05} n \ntrailer\n<<", root.obj_number, root.gen_number).bytes());
out.extend_from_slice(inner);
out.extend(format!(" /Prev {prev} >>\nstartxref\n{xref}\n%%EOF\n").bytes());
Some(out)
}
/// The offset the file's last `startxref` points to.
fn last_xref(bytes: &[u8]) -> Option<usize> {
let at = bytes.windows(9).rposition(|w| w == b"startxref")?;
let rest = bytes.get(at + 9..)?;
let digits: String = rest.iter().skip_while(|c| c.is_ascii_whitespace()).take_while(|c| c.is_ascii_digit()).map(|&c| c as char).collect();
digits.parse().ok().filter(|&p: &usize| p < bytes.len())
}
/// The trailer dictionary of the cross-reference section at `at` (a table's `trailer`, or a
/// cross-reference stream's dictionary), as written.
fn trailer_at(bytes: &[u8], at: usize) -> Option<&[u8]> {
let rest = bytes.get(at..)?;
let from = if rest.trim_ascii_start().starts_with(b"xref") { rest.windows(7).position(|w| w == b"trailer")? } else { 0 };
let open = from + rest.get(from..)?.windows(2).position(|w| w == b"<<")?;
let d = Dict::from_bytes(rest.get(open..)?)?;
Some(d.data())
}

View File

@ -0,0 +1,311 @@
//! Shadings: axial and radial ones become gradients (their stops sampled from the shading
//! function in its own colour model; an end that doesn't extend clips the painted area), patch
//! and triangle meshes become gradient meshes, and a gradient drawn through a luminosity mask that
//! is the same gradient in grey (how stop opacity is written) gets its opacity stops back.
use std::sync::Arc;
use hayro_interpret::pattern::ShadingPattern;
use hayro_interpret::shading::{ShadingFunction, ShadingType};
use kurbo::{BezPath, Circle, Point, Rect, Shape, Vec2};
use vectorcraft_color::{Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::live::{GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, MeshPoint, lerp_color};
use vectorcraft_doc::{Node, NodeKind, OpacityMask};
use crate::import::round3;
use crate::import_color::{Colors, Native};
use crate::import_mask::luminance;
/// The most mesh patches (or triangles) imported per shading: past it, the shading is a flat
/// colour.
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])> {
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());
// 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();
Some(Color::rgb(round3(r), round3(g), round3(b)))
};
// Sample densely, then drop samples that linear interpolation reproduces.
const N: usize = 64;
let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1);
for i in 0..=N {
let t = i as f32 / N as f32;
pts.push((t, sample(t)?.to_rgb()));
}
let mut keep = vec![0usize];
for w in 1..N {
let (Some(a), Some(p), Some(b)) = (keep.last().and_then(|&k| pts.get(k)), pts.get(w), pts.get(w + 1)) else { continue };
let u = (p.0 - a.0) / (b.0 - a.0).max(1e-6);
let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - p.1[k]).abs()).fold(0.0f32, f32::max);
if off > 1.5 / 255.0 {
keep.push(w);
}
}
keep.push(N);
let ts: Vec<f32> = keep.iter().filter_map(|&k| pts.get(k)).map(|p| p.0).collect();
let mut stop_at = |t: f32, offset: f32| -> GradientStop {
let rgb = sample(t).unwrap_or(Color::BLACK);
match at(t).and_then(|v| colors.native(space, &v)) {
Some(Native { color, link: Some((name, tint)) }) => GradientStop { swatch: Some(name), tint, ..GradientStop::new(offset, color) },
Some(Native { color, link: None }) => GradientStop::new(offset, color),
None => GradientStop::new(offset, rgb),
}
};
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
let (kind, geom, stops) = if *axial {
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0, focal: None };
(GradientKind::Linear, geom, ts.iter().map(|&t| stop_at(t, t)).collect::<Vec<_>>())
} else {
let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6));
// The end circle, mapped (as an ellipse) into the page; a start circle off its centre
// gives the focal point.
let p = |x: f32, y: f32| Point::new(x as f64, y as f64);
let centre = p(coords[3], coords[4]);
let mut geom = GradientGeom { start: centre, end: centre + Vec2::new(r1 as f64, 0.0), aspect: 1.0, focal: None };
geom.set_focal(Some(p(coords[0], coords[1])));
geom.transform(m, GradientKind::Radial);
// Offsets are relative to the outer radius; an inner radius shifts them outwards.
let stops = ts.iter().map(|&t| stop_at(t, (r0 + (r1 - r0) * t) / r1)).collect();
(GradientKind::Radial, geom, stops)
};
let mut g = GradientPaint::new(Gradient { kind, stops });
g.geom = Some(geom);
Some((g, *extend))
}
/// Where an axial or radial shading that doesn't extend at both ends paints, as a clip path in
/// document space (even-odd) for art filling `bounds`; `None` when it extends at both.
pub(crate) fn extend_clip(sp: &ShadingPattern, bounds: Rect) -> Option<BezPath> {
let ShadingType::RadialAxial { coords, axial, extend, .. } = sp.shading.shading_type.as_ref() else {
return None;
};
if extend[0] && extend[1] {
return None;
}
let m = sp.matrix;
let inv = m.inverse();
let local = inv.transform_rect_bbox(bounds);
if !local.is_finite() || m.determinant().abs() < 1e-12 {
return None;
}
let f = |i: usize| coords.get(i).copied().unwrap_or(0.0) as f64;
let mut out = BezPath::new();
if *axial {
let (p0, p1) = (Point::new(f(0), f(1)), Point::new(f(2), f(3)));
let len = (p1 - p0).hypot();
if len < 1e-9 {
return None;
}
let u = (p1 - p0) / len;
let n = Vec2::new(-u.y, u.x);
let corners = [local.origin(), Point::new(local.x1, local.y0), Point::new(local.x1, local.y1), Point::new(local.x0, local.y1)];
let along = corners.map(|c| (c - p0).dot(u));
let across = corners.map(|c| (c - p0).dot(n));
let min = |v: [f64; 4]| v.iter().copied().fold(f64::INFINITY, f64::min);
let max = |v: [f64; 4]| v.iter().copied().fold(f64::NEG_INFINITY, f64::max);
let lo = if extend[0] { min(along) } else { min(along).max(0.0) };
let hi = if extend[1] { max(along) } else { max(along).min(len) };
let (b0, b1) = (min(across), max(across));
if hi > lo {
let q = |a: f64, b: f64| m * (p0 + u * a + n * b);
out.move_to(q(lo, b0));
out.line_to(q(hi, b0));
out.line_to(q(hi, b1));
out.line_to(q(lo, b1));
out.close_path();
}
return Some(out);
}
let start = Circle::new((f(0), f(1)), f(2).max(0.0));
let end = Circle::new((f(3), f(4)), f(5).max(0.0));
// Past the end: the end circle bounds the paint; before the start: the start circle is a hole.
let outer = if extend[1] { local.inflate(1.0, 1.0).to_path(0.1) } else { end.to_path(0.1) };
out.extend(m * outer);
if !extend[0] && start.radius > 1e-9 {
out.extend(m * start.to_path(0.1));
}
Some(out)
}
/// 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 {
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());
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))
})
}
/// A 1×1 gradient mesh from a patch's four corners (bottom-left, top-left, top-right,
/// bottom-right in the patch's u/v order), each with its handles towards its neighbours.
fn patch_mesh(corners: [(Point, [Vec2; 4], Color); 4]) -> GradientMesh {
let point = |(p, handles, color): (Point, [Vec2; 4], Color)| MeshPoint { p, color, opacity: 1.0, handles };
let [a, b, c, d] = corners;
// Row 0 is v = 0 (corners a, d), row 1 is v = 1 (b, c); columns follow u.
GradientMesh { rows: 1, cols: 1, points: vec![point(a), point(d), point(b), point(c)] }
}
/// Handles of a mesh corner: right, left, down, up (unused ones stay at the point).
fn handles(right: Vec2, left: Vec2, down: Vec2, up: Vec2) -> [Vec2; 4] {
let mut h = [Vec2::ZERO; 4];
(h[H_RIGHT], h[H_LEFT], h[H_DOWN], h[H_UP]) = (right, left, down, up);
h
}
/// A 1×1 mesh from a patch's twelve boundary points (in the PDF's order: u runs cp0 → cp9 along
/// cp11 and cp10, v runs cp0 → cp3 along cp1 and cp2) and its corner colours (at cp0, cp3, cp6,
/// cp9); `None` if a point isn't finite.
fn boundary_mesh(cp: &[Point], colors: [Color; 4]) -> Option<GradientMesh> {
let cp: &[Point; 12] = cp.get(..12)?.try_into().ok()?;
if !cp.iter().all(|p| p.is_finite()) {
return None;
}
let h = |a: usize, b: usize| cp[b] - cp[a];
let [c0, c1, c2, c3] = colors;
Some(patch_mesh([
(cp[0], handles(h(0, 11), Vec2::ZERO, h(0, 1), Vec2::ZERO), c0),
(cp[3], handles(h(3, 4), Vec2::ZERO, Vec2::ZERO, h(3, 2)), c1),
(cp[6], handles(Vec2::ZERO, h(6, 5), Vec2::ZERO, h(6, 7)), c2),
(cp[9], handles(Vec2::ZERO, h(9, 10), h(9, 8), Vec2::ZERO), c3),
]))
}
/// 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>> {
let m = sp.matrix;
let mut out = vec![];
match sp.shading.shading_type.as_ref() {
ShadingType::CoonsPatchMesh { patches, function } => {
if patches.len() > MAX_PATCHES {
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));
out.extend(boundary_mesh(&p.control_points.map(|q| m * q), colors));
}
}
// A tensor patch's boundary is a Coons patch's (its four inner points are dropped).
ShadingType::TensorProductPatchMesh { patches, function } => {
if patches.len() > MAX_PATCHES {
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));
out.extend(boundary_mesh(&p.control_points.map(|q| m * q), colors));
}
}
ShadingType::TriangleMesh { triangles, function } => {
if triangles.len() > MAX_PATCHES {
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)));
if !v.iter().all(|(p, _)| p.is_finite()) {
continue;
}
let [a, b, c] = v;
out.push(patch_mesh([
(a.0, [Vec2::ZERO; 4], a.1),
(b.0, [Vec2::ZERO; 4], b.1),
(c.0, [Vec2::ZERO; 4], c.1),
(c.0, [Vec2::ZERO; 4], c.1),
]));
}
}
_ => return None,
}
Some(out)
}
/// The only painted leaf of mask art (through groups of one object and clip groups of one).
fn only_leaf(n: &Node) -> Option<&Node> {
match &n.kind {
NodeKind::Group { children, clip } => {
let rest = if *clip { children.get(1..)? } else { children.as_slice() };
match rest {
[one] if n.opacity >= 0.999 && n.mask.is_none() => only_leaf(one),
_ => None,
}
}
NodeKind::Path { .. } => Some(n),
_ => None,
}
}
/// The gradient a path paints with its only fill or stroke.
fn only_gradient(n: &Node) -> Option<&GradientPaint> {
match n.appearance.items.as_slice() {
[item] => match item.paint() {
Paint::Gradient(g) => Some(g),
_ => None,
},
_ => None,
}
}
fn same_geom(a: &GradientGeom, b: &GradientGeom) -> bool {
let near = |p: Point, q: Point| p.distance(q) < 0.5;
near(a.start, b.start) && near(a.end, b.end) && near(a.focal_point(), b.focal_point()) && (a.aspect - b.aspect).abs() < 0.01
}
/// `content` (a path painting one gradient) drawn through `mask` (clipped, not inverted, the same
/// gradient in shades of grey: how gradient stop opacity is written) as one gradient with the
/// mask's luminance as stop opacity.
pub(crate) fn fold_stop_opacity(content: &Node, mask: &OpacityMask) -> Option<Node> {
if !mask.clip || mask.invert || mask.disabled || content.is_container() {
return None;
}
let g = only_gradient(content)?;
let leaf = only_leaf(&mask.art)?;
let mg = only_gradient(leaf)?;
let opaque = |n: &Node| n.opacity >= 0.999 && n.appearance.items.iter().all(|i| i.opacity() >= 0.999);
if mg.gradient.kind != g.gradient.kind || !opaque(leaf) || !same_geom(g.geom.as_ref()?, mg.geom.as_ref()?) {
return None;
}
let mut offsets: Vec<f32> = g.gradient.stops.iter().chain(&mg.gradient.stops).map(|s| s.offset).collect();
offsets.sort_by(f32::total_cmp);
offsets.dedup_by(|a, b| (*a - *b).abs() < 1e-4);
let stops = offsets
.iter()
.map(|&t| {
let (mc, mo) = mg.gradient.sample(t);
let alpha = round3(luminance(&mc) * mo);
let mut s = match g.gradient.stops.iter().find(|s| (s.offset - t).abs() < 1e-4) {
Some(s) => s.clone(),
None => {
let (c, o) = g.gradient.sample_with(t, lerp_color);
GradientStop { opacity: o, ..GradientStop::new(t, c) }
}
};
s.opacity = round3(s.opacity * alpha);
s
})
.collect();
let mut out = content.clone();
let mut paint = (*g).clone();
paint.gradient.stops = stops;
if let Some(item) = out.appearance.items.first_mut() {
*item.paint_mut() = Paint::Gradient(Box::new(paint));
}
Some(out)
}
/// `node` (filling `bounds`) clipped to `clip`: a clip group.
pub(crate) fn clipped(clip: Node, node: Node, id: vectorcraft_doc::NodeId) -> Node {
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(node)], clip: true })
}

View File

@ -0,0 +1,225 @@
//! Text as editable point type: glyphs drawn one after another on the same baseline make one
//! point text object of their Unicode text, a run per font, size and paint (a gap wider than a
//! fifth of the size reads as a space). Fonts are named from the file's base font name (its
//! subset prefix dropped, the family matched against the fonts available); a font that isn't
//! available keeps its name, and the text shows in the fallback font until it is.
use std::collections::HashMap;
use kurbo::{Affine, Point, Vec2};
use vectorcraft_color::Paint;
use vectorcraft_doc::{CharStyle, TextObject, TextRun};
/// One glyph's placement: its baseline origin, advance direction, size and horizontal scale.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Placement {
pub origin: Point,
/// Unit vector along the baseline.
pub dir: Vec2,
/// Font size in points (the em's height).
pub size: f64,
/// Width of the em ÷ its height, in percent.
pub h_scale: f64,
}
impl Placement {
/// Where a glyph drawn with `m` (glyph space, 1000 units per em, y up → document) sits;
/// `None` when it is mirrored, degenerate or not finite (those keep their outlines).
pub fn of(m: Affine) -> Option<Self> {
let origin = m * Point::ORIGIN;
let ex = m * Point::new(1000.0, 0.0) - origin;
let ey = m * Point::new(0.0, 1000.0) - origin;
let (w, size) = (ex.hypot(), ey.hypot());
// Upright in y-down document space: x to the right of up.
let upright = ex.cross(ey) < 0.0;
(origin.is_finite() && w.is_finite() && size.is_finite() && w > 0.01 && size > 0.01 && size < 1e5 && upright).then(|| Self {
origin,
dir: ex / w,
size,
h_scale: w / size * 100.0,
})
}
fn up(&self) -> Vec2 {
Vec2::new(self.dir.y, -self.dir.x)
}
}
/// What a run of type is drawn with: its font (cache key, family and style), size, horizontal
/// scale, fill and stroke (paint and width).
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Look {
pub font: u128,
pub family: String,
pub style: String,
pub size: f64,
pub h_scale: f64,
pub fill: Option<Paint>,
pub stroke: Option<(Paint, f64)>,
}
impl Look {
/// Can a glyph drawn with `other` join a run drawn with this? A fill joins a run whose
/// glyphs were also stroked over (fill and stroke rendering draws each glyph twice).
fn takes(&self, other: &Look) -> bool {
self.font == other.font
&& (self.size - other.size).abs() <= self.size * 0.01
&& (self.h_scale - other.h_scale).abs() < 0.5
&& self.fill == other.fill
&& (other.stroke.is_none() || self.stroke == other.stroke)
}
fn style(&self) -> CharStyle {
let (stroke, stroke_width) = self.stroke.clone().unwrap_or((Paint::None, 0.0));
CharStyle {
font_family: self.family.clone(),
font_style: self.style.clone(),
size: round(self.size),
h_scale: round(self.h_scale),
fill: self.fill.clone().unwrap_or(Paint::None),
stroke,
stroke_width,
..CharStyle::default()
}
}
}
/// A line of type being gathered: runs of glyphs on one baseline.
pub(crate) struct TextLine {
/// The first glyph's placement.
at: Placement,
opacity: f32,
/// Where the next glyph would start without spacing.
next: Point,
/// The last glyph's origin.
last: Point,
runs: Vec<(Look, String)>,
}
impl TextLine {
pub fn new(at: Placement, opacity: f32) -> Self {
Self { at, opacity, next: at.origin, last: at.origin, runs: vec![] }
}
/// Add glyph `text` drawn with `look` at `at` (advancing `advance` points) at `opacity` if
/// it continues this line; `false`: it starts another.
pub fn push(&mut self, look: &Look, at: Placement, opacity: f32, advance: f64, text: &str) -> bool {
if let Some((last, run)) = self.runs.last_mut() {
let size = self.at.size.max(at.size);
let gap = (at.origin - self.next).dot(self.at.dir);
let on_line = opacity == self.opacity
&& at.dir.dot(self.at.dir) > 0.9995
&& (at.origin - self.at.origin).dot(self.at.up()).abs() < size * 0.15
&& gap > -size * 0.3
&& gap < size * 3.0;
if !on_line {
return false;
}
// A gap wider than a fifth of an em reads as a space.
if gap > size * 0.2 && !run.ends_with(' ') && !text.starts_with(' ') {
run.push(' ');
}
if last.takes(look) {
run.push_str(text);
} else {
self.runs.push((look.clone(), text.to_string()));
}
} else {
self.runs.push((look.clone(), text.to_string()));
}
self.next = at.origin + self.at.dir * advance;
self.last = at.origin;
true
}
/// A stroke (`stroke`: paint and width) over the glyph just drawn at `at` in `font` (fill
/// and stroke rendering): the run is stroked. `false`: it isn't that glyph.
pub fn stroke_last(&mut self, font: u128, at: Placement, stroke: (Paint, f64)) -> bool {
let Some((look, _)) = self.runs.last_mut().filter(|(l, _)| l.font == font && (at.origin - self.last).hypot() < self.at.size * 1e-3) else {
return false;
};
look.stroke = Some(stroke);
true
}
/// The point type object and its opacity.
pub fn finish(mut self) -> Option<(TextObject, f32)> {
if let Some((_, last)) = self.runs.last_mut() {
last.truncate(last.trim_end().len());
}
self.runs.retain(|(_, t)| !t.is_empty());
if self.runs.iter().all(|(_, t)| t.trim().is_empty()) {
return None;
}
let mut runs = self.runs.into_iter().map(|(look, text)| TextRun { text, style: look.style() });
let first = runs.next()?;
let mut t = TextObject::point(Point::ORIGIN, &first.text, first.style);
t.runs.extend(runs);
t.xf = Affine::translate(self.at.origin.to_vec2()) * Affine::rotate(self.at.dir.atan2());
Some((t, self.opacity))
}
}
fn round(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
/// Lower-case letters and digits only (`"Source Sans 3"` → `"sourcesans3"`).
fn norm(s: &str) -> String {
s.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
}
/// `"TimesNewRoman"` → `"Times New Roman"`, `"SourceSans3"` → `"Source Sans 3"`.
fn spaced(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
let mut prev: Option<char> = None;
for c in s.chars() {
if let Some(p) = prev
&& ((p.is_ascii_lowercase() && c.is_ascii_uppercase()) || (p.is_ascii_alphabetic() && c.is_ascii_digit()))
{
out.push(' ');
}
out.push(c);
prev = Some(c);
}
out
}
/// `name` without a PostScript name's trailing `PSMT`, `MT` or `PS`.
fn strip_ps(name: &str) -> &str {
["PSMT", "MT", "PS"].iter().find_map(|s| name.strip_suffix(s).filter(|r| !r.is_empty())).unwrap_or(name)
}
/// The families available, by [`norm`]ed name.
pub(crate) struct Families(HashMap<String, String>);
impl Families {
pub fn available() -> Self {
Self(vectorcraft_text::FontDb::global().families().into_iter().map(|f| (norm(&f), f)).collect())
}
/// The family and style of a font with base (PostScript) name `name`, or of weight `weight`
/// and slant `italic` when the name has no style; whether the family is available.
pub fn resolve(&self, name: &str, weight: Option<u32>, italic: bool) -> (String, String, bool) {
// A subset's six-letter tag.
let name = match name.split_once('+') {
Some((tag, rest)) if tag.len() == 6 && tag.chars().all(|c| c.is_ascii_uppercase()) => rest,
_ => name,
};
let (fam, style) = name.split_once(['-', ',']).unwrap_or((name, ""));
let found = [fam, strip_ps(fam)].iter().find_map(|f| self.0.get(&norm(f)).cloned());
let style = match spaced(strip_ps(style)).as_str() {
"" | "Roman" | "Book" | "Normal" | "Plain" => match (weight.is_some_and(|w| w >= 600) || style.contains("Bold"), italic) {
(true, true) => "Bold Italic".to_string(),
(true, false) => "Bold".to_string(),
(false, true) => "Italic".to_string(),
(false, false) => "Regular".to_string(),
},
s => s.to_string(),
};
match found {
Some(f) => (f, style, true),
None => (spaced(strip_ps(fam)).trim().to_string(), style, false),
}
}
}

View File

@ -11,9 +11,13 @@
//! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve
//! Editing the native document rides along as an embedded file ([`editing()`]).
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups,
//! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others
//! re-encoded as PNG) and text as glyph outlines. Colours keep their model: CMYK, Gray, and spot
//! [`Document`]: one artboard per page, and a layer per page or per optional content group
//! (with its visibility, print state and lock; art that is off comes in as a hidden layer),
//! paths with fill/stroke, clip groups, transparency groups (with isolation and knockout), soft
//! masks → opacity masks, axial/radial shadings → gradients (stop opacity and unextended ends
//! kept), mesh shadings → gradient meshes, tiling patterns → pattern swatches, images (JPEG
//! passthrough, others re-encoded as PNG) and text as point type (or glyph outlines, see
//! [`TextAs`]). Colours keep their model: CMYK, Gray, and spot
//! inks (Separation, DeviceN) as spot swatches at a tint; a file painted mostly in CMYK opens as a
//! CMYK document. [`ImportOptions`] pick the pages, the box each
//! artboard gets ([`CropTo`]) and the password; [`info`] lists the pages and their boxes.
@ -26,6 +30,10 @@ mod export;
mod images;
mod import;
mod import_color;
mod import_mask;
mod import_scan;
mod import_shading;
mod import_text;
mod lab_spot;
mod pages;
mod presets;
@ -76,14 +84,29 @@ pub struct ImportOptions {
pub crop: CropTo,
/// The (user) password of an encrypted PDF.
pub password: Option<String>,
/// What text becomes.
pub text_as: TextAs,
/// Optional content groups become layers (else each page is one layer, without the art
/// that is off).
pub layers: bool,
}
impl Default for ImportOptions {
fn default() -> Self {
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None }
Self { max_pages: None, artboard_gap: 36.0, pages: None, crop: CropTo::default(), password: None, text_as: TextAs::default(), layers: true }
}
}
settings::choice! {
/// What imported text becomes.
TextAs {
/// Point type, one object per line run, in the file's fonts (by name).
Text = "text", "Text";
/// Paths of the glyphs' outlines (looks the same without the fonts).
Outlines = "outlines", "Outlines";
} default Text
}
settings::choice! {
/// The page box an imported or placed page is cropped to: its artboard (or placed frame).
CropTo {
@ -168,6 +191,10 @@ mod tests_fx;
#[cfg(test)]
mod tests_import_color;
#[cfg(test)]
mod tests_import_fidelity;
#[cfg(test)]
mod tests_import_layers;
#[cfg(test)]
mod tests_import_options;
#[cfg(test)]
mod tests_presets;

View File

@ -0,0 +1,361 @@
//! Import fidelity: soft masks → opacity masks, isolated and knockout groups, tiling patterns →
//! pattern swatches, text → point type, mesh shadings → gradient meshes, gradient stop opacity
//! and shading Extend flags.
use std::sync::Arc;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Knockout, Node, NodeId, NodeKind, OpacityMask};
use vectorcraft_geom::{Rect, shapes};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with};
use crate::*;
/// A stream object with dictionary entries `dict`.
pub(crate) fn stream(dict: &str, data: &str) -> String {
format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len())
}
/// A 100 × 100 pt page drawing `content` with `resources`, plus `extra` objects.
fn one_page(content: &str, resources: &str, extra: &[&str]) -> Vec<u8> {
let page = PdfPage { resources: resources.into(), ..PdfPage::new(100.0, 100.0, content) };
pdf_with(&[page], extra, None)
}
fn open(bytes: &[u8]) -> Document {
import(bytes).unwrap()
}
fn open_with(bytes: &[u8], f: impl FnOnce(&mut ImportOptions)) -> ImportReport {
let mut o = ImportOptions::default();
f(&mut o);
import_with_report(bytes, &o).unwrap()
}
/// Every node of the document, depth first.
fn all(d: &Document) -> Vec<Node> {
let mut out = vec![];
d.walk(|n| out.push(n.clone()));
out
}
fn rect(r: Rect, c: Color) -> Node {
Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(c), Paint::None, 0.0))
}
fn add(d: &mut Document, mut n: Node) {
n.id = d.alloc_id();
let l = d.layers[0].id;
d.insert(Some(l), usize::MAX, n).unwrap();
}
fn roundtrip(d: &Document) -> Document {
open(&export(d, &PdfOptions::default()).unwrap())
}
/// The fill colour of a node's first fill.
fn fill_color(n: &Node) -> Option<Color> {
n.appearance.fill().and_then(|f| f.paint.color())
}
fn near(a: Rect, b: Rect) -> bool {
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|v| v.abs() < 0.6)
}
// ---------- soft masks ----------
/// A page filling a red square through soft mask `smask` (its group, object 5, draws `mask`).
fn masked(smask: &str, mask: &str) -> Vec<u8> {
let g = first_extra(1);
let group = stream("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >>", mask);
one_page("/GS0 gs 1 0 0 rg 0 0 100 100 re f", &format!("/ExtGState << /GS0 << /SMask << /Type /Mask {smask} /G {g} 0 R >> >> >>"), &[&group])
}
fn the_mask(d: &Document) -> (Node, OpacityMask) {
let n = all(d).into_iter().find(|n| n.mask.is_some()).expect("a masked object");
let m = n.mask.as_deref().unwrap().clone();
(n, m)
}
#[test]
fn a_luminosity_soft_mask_becomes_an_opacity_mask() {
let d = open(&masked("/S /Luminosity", "1 1 1 rg 0 0 50 100 re f"));
let (n, m) = the_mask(&d);
assert!(n.path_data().is_some(), "the mask is on the square itself");
assert!(m.clip && !m.invert);
assert_eq!(fill_color(&m.art).map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0]));
assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 50.0, 100.0)), "{:?}", m.art.geometric_bounds());
// A white backdrop: outside the art stays visible (no clip); an inverting transfer function inverts.
let (_, m) = the_mask(&open(&masked("/S /Luminosity /BC [1 1 1]", "0 0 0 rg 0 0 50 100 re f")));
assert!(!m.clip);
let tr = "/TR << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>";
let (_, m) = the_mask(&open(&masked(&format!("/S /Luminosity {tr}"), "1 1 1 rg 0 0 50 100 re f")));
assert!(m.invert && m.clip);
}
#[test]
fn an_alpha_soft_mask_becomes_an_opacity_mask_of_its_shape() {
// The art is painted white with its opacity kept: its luminance is its alpha.
let bytes = {
let g = first_extra(1);
let group = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /ExtGState << /GA << /ca 0.5 >> >> >>",
"/GA gs 0 0 1 rg 0 0 50 100 re f",
);
one_page(
"/GS0 gs 1 0 0 rg 0 0 100 100 re f",
&format!("/ExtGState << /GS0 << /SMask << /Type /Mask /S /Alpha /G {g} 0 R >> >> >>"),
&[&group],
)
};
let (_, m) = the_mask(&open(&bytes));
assert!(m.clip && !m.invert);
let f = m.art.appearance.fill().unwrap();
assert_eq!(f.paint.color().map(|c| c.to_rgb()), Some([1.0, 1.0, 1.0]));
assert!((f.opacity * m.art.opacity - 0.5).abs() < 0.01);
}
#[test]
fn opacity_masks_round_trip_through_pdf() {
for (clip, invert) in [(true, false), (false, false), (true, true), (false, true)] {
let mut d = Document::new(100.0, 100.0);
let mut n = rect(Rect::new(10.0, 10.0, 90.0, 90.0), Color::rgb(1.0, 0.0, 0.0));
let art = rect(Rect::new(10.0, 10.0, 50.0, 90.0), Color::gray(0.25));
n.mask = Some(Box::new(OpacityMask { invert, ..OpacityMask::new(art, clip) }));
add(&mut d, n);
let back = roundtrip(&d);
let (n, m) = the_mask(&back);
assert!(near(n.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 90.0)));
assert_eq!((m.clip, m.invert), (clip, invert), "clip {clip}, invert {invert}");
assert!(near(m.art.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 50.0, 90.0)), "{:?}", m.art.geometric_bounds());
let lum = crate::import_mask::luminance(&fill_color(&m.art).unwrap());
assert!((lum - 0.75).abs() < 0.02, "{lum}");
}
}
// ---------- isolation and knockout ----------
/// A 50% group (isolated or not) holding a Multiply square over a backdrop.
fn half_group(isolate: bool) -> Document {
let mut d = Document::new(100.0, 100.0);
add(&mut d, rect(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.8, 0.6, 0.2)));
let mut m = rect(Rect::new(20.0, 20.0, 80.0, 80.0), Color::rgb(0.5, 0.5, 1.0));
(m.id, m.blend) = (d.alloc_id(), BlendMode::Multiply);
let mut g = Node::group(NodeId(0), vec![Arc::new(m)]);
(g.opacity, g.isolate) = (0.5, isolate);
add(&mut d, g);
d
}
#[test]
fn isolated_and_non_isolated_groups_round_trip() {
let back = roundtrip(&half_group(true));
let groups: Vec<Node> = all(&back).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: false, .. })).collect();
let [g] = groups.as_slice() else { panic!("one group: {groups:?}") };
assert!(g.isolate && g.mask.is_none());
assert!((g.opacity - 0.5).abs() < 0.01, "{}", g.opacity);
assert_eq!(g.children().unwrap()[0].blend, BlendMode::Multiply);
// Not isolated: its 50% (a constant alpha mask in the file) is opacity again, here on the
// only object inside, which blends with the backdrop as before.
let back = roundtrip(&half_group(false));
let nodes = all(&back);
assert!(nodes.iter().all(|n| !n.isolate && n.mask.is_none()));
let m = nodes.iter().find(|n| n.blend == BlendMode::Multiply).unwrap();
let opacity: f32 =
nodes.iter().filter(|n| n.children().is_some_and(|c| c.iter().any(|c| c.id == m.id))).map(|g| g.opacity).product::<f32>() * m.opacity;
assert!((opacity - 0.5).abs() < 0.01, "{opacity}");
}
#[test]
fn isolate_and_knockout_flags_of_a_group_are_kept() {
let x = first_extra(1);
let form = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true /K true >> /Resources << /ExtGState << /M << /BM /Multiply >> >> >>",
"/M gs 1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f",
);
let d = open(&one_page("0 1 0 rg 0 0 100 100 re f /X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&form]));
let groups: Vec<Node> = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).collect();
let [g] = groups.as_slice() else { panic!("one group: {groups:?}") };
assert!(g.isolate);
assert_eq!(g.knockout, Knockout::On);
assert_eq!(g.children().unwrap().len(), 2);
// Without the flags, the form's group is just its art.
let plain = stream(
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency >>",
"1 0 0 rg 10 10 50 50 re f 0 0 1 rg 30 30 50 50 re f",
);
let d = open(&one_page("/X0 Do", &format!("/XObject << /X0 {x} 0 R >>"), &[&plain]));
assert!(all(&d).iter().all(|n| !matches!(n.kind, NodeKind::Group { .. })));
}
// ---------- tiling patterns ----------
#[test]
fn tiling_patterns_become_pattern_swatches() {
let p = first_extra(1);
let colored = stream("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "1 0 0 rg 0 0 5 5 re f");
let d = open(&one_page("/Pattern cs /P0 scn 0 0 100 50 re f 0 50 100 50 re f", &format!("/Pattern << /P0 {p} 0 R >>"), &[&colored]));
assert_eq!(d.patterns.len(), 1, "one swatch for both fills");
let def = &d.patterns[0];
assert!(near(def.tile, Rect::new(0.0, 0.0, 10.0, 10.0)), "{:?}", def.tile);
assert_eq!(def.art.len(), 1);
assert_eq!(fill_color(&def.art[0]), Some(Color::rgb(1.0, 0.0, 0.0)));
assert!(d.swatches.iter().any(|s| matches!(&s.paint, Paint::Pattern { pattern, .. } if *pattern == def.name)), "a pattern swatch");
let fills: Vec<Paint> = all(&d).iter().filter_map(|n| n.appearance.fill().map(|f| f.paint.clone())).collect();
assert_eq!(fills.len(), 2);
assert!(fills.iter().all(|f| matches!(f, Paint::Pattern { pattern, .. } if *pattern == def.name)), "{fills:?}");
// An uncoloured pattern draws in the current colour: one swatch per colour.
let stencil = stream("/PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>", "0 0 5 5 re f");
let d = open(&one_page(
"/CS0 cs 1 0 0 /P0 scn 0 0 100 50 re f 0 0 1 /P0 scn 0 50 100 50 re f",
&format!("/Pattern << /P0 {p} 0 R >> /ColorSpace << /CS0 [/Pattern /DeviceRGB] >>"),
&[&stencil],
));
let colors: Vec<Color> = d.patterns.iter().filter_map(|p| fill_color(&p.art[0])).collect();
assert_eq!(colors, vec![Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)]);
}
// ---------- text ----------
const HELVETICA: &str =
"/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> /F2 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >> >>";
fn texts(d: &Document) -> Vec<vectorcraft_doc::TextObject> {
all(d).into_iter().filter_map(|n| if let NodeKind::Text(t) = n.kind { Some(*t) } else { None }).collect()
}
#[test]
fn text_becomes_point_type() {
let bytes = one_page(
"BT /F1 24 Tf 1 0 0 rg 10 30 Td (Hello) Tj [(Wor) -20 (ld)] TJ ET BT /F1 12 Tf 10 70 Td [(Next) -1000 (line)] TJ /F2 12 Tf 0 0 1 rg ( in bold) Tj ET",
HELVETICA,
&[],
);
let r = open_with(&bytes, |_| {});
let t = texts(&r.document);
assert_eq!(t.len(), 2, "{t:?}");
let text = |o: &vectorcraft_doc::TextObject| o.runs.iter().map(|r| r.text.as_str()).collect::<String>();
assert_eq!(text(&t[0]), "HelloWorld");
let st = &t[0].runs[0].style;
assert_eq!((st.font_family.as_str(), st.font_style.as_str(), st.size), ("Helvetica", "Regular", 24.0));
assert_eq!(st.fill.color(), Some(Color::rgb(1.0, 0.0, 0.0)));
// The baseline origin, in y-down page space.
let o = t[0].xf * kurbo::Point::ORIGIN;
assert!((o.x - 10.0).abs() < 0.01 && (o.y - 70.0).abs() < 0.01, "{o:?}");
// A gap of an em between words reads as a space; another font on the line is another run.
assert_eq!(text(&t[1]), "Next line in bold");
let runs: Vec<(&str, &str)> = t[1].runs.iter().map(|r| (r.text.as_str(), r.style.font_style.as_str())).collect();
assert_eq!(runs, [("Next line", "Regular"), (" in bold", "Bold")]);
assert_eq!(t[1].runs[1].style.fill.color(), Some(Color::rgb(0.0, 0.0, 1.0)));
assert!(!r.warnings.iter().any(|w| w.contains("outlines")), "{:?}", r.warnings);
// As outlines: paths, no type.
let r = open_with(&bytes, |o| o.text_as = TextAs::Outlines);
assert!(texts(&r.document).is_empty());
assert!(all(&r.document).iter().any(|n| n.name.as_deref() == Some("<Text Outlines>")));
}
#[test]
fn font_names_resolve_to_available_families() {
let f = crate::import_text::Families::available();
let (family, style, found) = f.resolve("ABCDEF+SourceSans3-Bold", None, false);
assert_eq!((family.as_str(), style.as_str(), found), ("Source Sans 3", "Bold", true));
let (family, style, found) = f.resolve("TimesNewRomanPS-BoldItalicMT", None, false);
assert_eq!((family.as_str(), style.as_str()), ("Times New Roman", "Bold Italic"));
let _ = found;
let (family, style, found) = f.resolve("MissingSansPro", Some(700), true);
assert_eq!((family.as_str(), style.as_str(), found), ("Missing Sans Pro", "Bold Italic", false));
}
// ---------- meshes, stops and extend ----------
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02X}")).collect::<Vec<_>>().join("") + ">"
}
#[test]
fn a_coons_patch_mesh_becomes_a_gradient_mesh() {
// One patch over (10, 10)–(90, 90): corner colours red (0, 0), green (0, 1), blue (1, 1),
// white (1, 0) in u/v.
let pts: [(u8, u8); 12] =
[(10, 10), (10, 37), (10, 63), (10, 90), (37, 90), (63, 90), (90, 90), (90, 63), (90, 37), (90, 10), (63, 10), (37, 10)];
let mut data = vec![0u8];
for (x, y) in pts {
data.extend([x, y]);
}
data.extend([255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]);
let s = first_extra(1);
let sh = stream(
"/ShadingType 6 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
&hex(&data),
);
let d = open(&one_page("/Sh0 sh", &format!("/Shading << /Sh0 {s} 0 R >>"), &[&sh]));
let meshes: Vec<vectorcraft_doc::live::GradientMesh> =
all(&d).into_iter().filter_map(|n| if let NodeKind::Mesh(m) = n.kind { Some(m) } else { None }).collect();
let [m] = meshes.as_slice() else { panic!("one mesh: {meshes:?}") };
assert_eq!((m.rows, m.cols), (1, 1));
let at = |r, c| &m.points[m.idx(r, c)];
// Row 0 is v = 0 (PDF y 10 → page y 90), column 1 is u = 1.
assert!((at(0, 0).p - kurbo::Point::new(10.0, 90.0)).hypot() < 0.01, "{:?}", at(0, 0).p);
assert!((at(1, 1).p - kurbo::Point::new(90.0, 10.0)).hypot() < 0.01, "{:?}", at(1, 1).p);
assert_eq!(at(0, 0).color.to_rgb(), [1.0, 0.0, 0.0]);
assert_eq!(at(1, 0).color.to_rgb(), [0.0, 1.0, 0.0]);
assert_eq!(at(1, 1).color.to_rgb(), [0.0, 0.0, 1.0]);
assert_eq!(at(0, 1).color.to_rgb(), [1.0, 1.0, 1.0]);
assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })), "the mesh lies inside the page");
}
#[test]
fn gradient_stop_opacity_round_trips() {
let mut d = Document::new(100.0, 100.0);
let mut stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
stops[1].opacity = 0.2;
let g = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
let n = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)),
Appearance::basic(Paint::Gradient(Box::new(g)), Paint::None, 0.0),
);
add(&mut d, n);
let back = roundtrip(&d);
let nodes = all(&back);
assert!(nodes.iter().all(|n| n.mask.is_none()), "the opacity is in the stops, not a mask");
let g = nodes.iter().find_map(|n| match n.appearance.fill().map(|f| &f.paint) {
Some(Paint::Gradient(g)) => Some(g.gradient.clone()),
_ => None,
});
let g = g.expect("a gradient fill");
let first = g.stops.first().unwrap();
let last = g.stops.last().unwrap();
assert!((first.opacity - 1.0).abs() < 0.02 && (last.opacity - 0.2).abs() < 0.02, "{:?}", g.stops);
assert_eq!(last.color.to_rgb(), [0.0, 0.0, 1.0]);
}
#[test]
fn a_shading_that_does_not_extend_paints_only_between_its_ends() {
let shading = |extend: &str| {
format!(
"/Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [20 0 80 0] /Extend [{extend}] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >>"
)
};
let d = open(&one_page("/Sh0 sh", &shading("false false"), &[]));
let clips: Vec<Node> = all(&d).into_iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
let [c] = clips.as_slice() else { panic!("one clip group: {clips:?}") };
let b = c.geometric_bounds().unwrap();
assert!((b.x0 - 20.0).abs() < 0.01 && (b.x1 - 80.0).abs() < 0.01 && b.y0 <= 0.0 && b.y1 >= 100.0, "{b:?}");
let d = open(&one_page("/Sh0 sh", &shading("true true"), &[]));
assert!(!all(&d).iter().any(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })));
// Extended at the start only: from the left edge to the end.
let d = open(&one_page("/Sh0 sh", &shading("true false"), &[]));
let b = all(&d).into_iter().find(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).unwrap().geometric_bounds().unwrap();
assert!(b.x0 <= 0.0 && (b.x1 - 80.0).abs() < 0.01, "{b:?}");
}
#[test]
fn appearance_items_are_untouched_for_plain_art() {
// Plain art keeps importing as before: no masks, groups or clips appear.
let d = open(&one_page("q 1 0 0 rg 10 10 30 30 re f Q 0 0 1 RG 2 w 50 50 30 30 re S", "", &[]));
let nodes = all(&d);
assert_eq!(nodes.iter().filter(|n| n.path_data().is_some()).count(), 2);
assert!(nodes.iter().all(|n| n.mask.is_none() && !matches!(n.kind, NodeKind::Group { .. })));
assert!(matches!(nodes.iter().find(|n| n.appearance.stroke().is_some()).unwrap().appearance.items[0], AppearanceItem::Stroke(_)));
}

View File

@ -0,0 +1,136 @@
//! Optional content groups become layers: name, visibility, print state and lock; art that is
//! off comes in as a hidden layer.
use vectorcraft_color::Color;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with_catalog};
use crate::tests_import_fidelity::stream;
use crate::*;
/// Groups "Shapes" and "Hidden" (not printed) as the first extra objects of a file of `pages`
/// pages, the catalog entries listing them with `config` (where `{A}` and `{B}` stand for them)
/// as the default configuration, and their object numbers.
fn groups(pages: usize, config: &str) -> ([String; 2], String, [usize; 2]) {
let (a, b) = (first_extra(pages), first_extra(pages) + 1);
let objs = [
"<< /Type /OCG /Name (Shapes) >>".to_string(),
"<< /Type /OCG /Name <FEFF00480069006400640065006E> /Usage << /Print << /PrintState /OFF >> >> >>".to_string(),
];
let catalog = format!(
"/OCProperties << /OCGs [{a} 0 R {b} 0 R] /D << /Order [{a} 0 R {b} 0 R] {} >> >> ",
config.replace("{A}", &format!("{a} 0 R")).replace("{B}", &format!("{b} 0 R"))
);
(objs, catalog, [a, b])
}
/// A page drawing red in "Shapes", blue in "Hidden" and green outside any group.
fn page(ids: [usize; 2]) -> PdfPage {
PdfPage {
resources: format!("/Properties << /MC0 {} 0 R /MC1 {} 0 R >>", ids[0], ids[1]),
..PdfPage::new(100.0, 100.0, "/OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC /OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC 0 1 0 rg 0 0 5 5 re f")
}
}
fn file(config: &str) -> Vec<u8> {
let (objs, catalog, ids) = groups(1, config);
pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, None)
}
fn names(d: &Document) -> Vec<String> {
d.layers.iter().map(|l| l.name.clone().unwrap_or_default()).collect()
}
/// The fill colours of a layer's art.
fn colors(l: &Node) -> Vec<Color> {
l.children().unwrap().iter().filter_map(|n| n.appearance.fill().and_then(|f| f.paint.color())).collect()
}
fn printable(l: &Node) -> bool {
matches!(l.kind, NodeKind::Layer { printable: true, .. })
}
#[test]
fn optional_content_groups_become_layers_and_off_art_a_hidden_layer() {
let d = import(&file("/OFF [{B}]")).unwrap();
// In paint order (bottom first): the groups, then the art outside them.
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]);
let [shapes, hidden, rest] = [0, 1, 2].map(|i| d.layers[i].clone());
assert!(shapes.visible && printable(&shapes) && !shapes.locked);
assert_eq!(colors(&shapes), [Color::rgb(1.0, 0.0, 0.0)]);
// The group that is off is a hidden layer that still has its art; it doesn't print.
assert!(!hidden.visible && !printable(&hidden));
assert_eq!(colors(&hidden), [Color::rgb(0.0, 0.0, 1.0)]);
assert_eq!(colors(&rest), [Color::rgb(0.0, 1.0, 0.0)]);
}
#[test]
fn base_state_on_lists_and_locks_are_read() {
let d = import(&file("/BaseState /OFF /ON [{A}] /Locked [{A}]")).unwrap();
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1"]);
assert!(d.layers[0].visible && d.layers[0].locked);
assert!(!d.layers[1].visible && !d.layers[1].locked);
}
#[test]
fn without_layers_each_page_is_one_layer_of_what_shows() {
let r = import_with_report(&file("/OFF [{B}]"), &ImportOptions { layers: false, ..Default::default() }).unwrap();
assert_eq!(names(&r.document), ["Page 1"]);
assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 1.0, 0.0)]);
}
#[test]
fn pages_share_the_layers_of_their_groups() {
let (objs, catalog, ids) = groups(2, "/OFF [{B}]");
let bytes = pdf_with_catalog(&[page(ids), page(ids)], &[&objs[0], &objs[1]], &catalog, None);
let d = import(&bytes).unwrap();
assert_eq!(names(&d), ["Shapes", "Hidden", "Page 1", "Page 2"]);
assert_eq!(colors(&d.layers[0]).len(), 2, "both pages' art");
assert_eq!(colors(&d.layers[1]).len(), 2);
// The second page's art sits on its artboard.
let x = d.layers[0].children().unwrap()[1].geometric_bounds().unwrap().x0;
assert!(x > d.artboards[1].rect.x0, "{x}");
}
#[test]
fn groups_marked_inside_forms_and_clips_reach_their_layers() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let form_no = first_extra(1) + 2;
let form = stream(
&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Properties << /MC1 {} 0 R >> >>", ids[1]),
"/OC /MC1 BDC 0 0 1 rg 50 50 30 30 re f EMC",
);
let page = PdfPage {
resources: format!("/Properties << /MC0 {} 0 R >> /XObject << /X0 {form_no} 0 R >>", ids[0]),
..PdfPage::new(100.0, 100.0, "q 0 0 25 25 re W n /OC /MC0 BDC 1 0 0 rg 10 10 30 30 re f EMC Q /X0 Do")
};
let d = import(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None)).unwrap();
assert_eq!(names(&d), ["Shapes", "Hidden"]);
let shapes = &d.layers[0].children().unwrap()[0];
assert!(matches!(shapes.kind, NodeKind::Group { clip: true, .. }), "the clip group goes with its art");
assert!(!d.layers[1].visible);
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
}
#[test]
fn a_form_hidden_by_its_own_group_stays_hidden() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let form_no = first_extra(1) + 2;
let form = stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /OC {} 0 R", ids[1]), "0 0 1 rg 50 50 30 30 re f");
let page = PdfPage { resources: format!("/XObject << /X0 {form_no} 0 R >>"), ..PdfPage::new(100.0, 100.0, "1 0 0 rg 10 10 30 30 re f /X0 Do") };
let r = import_with_report(&pdf_with_catalog(&[page], &[&objs[0], &objs[1], &form], &catalog, None), &ImportOptions::default()).unwrap();
assert_eq!(names(&r.document), ["Page 1"]);
assert_eq!(colors(&r.document.layers[0]), [Color::rgb(1.0, 0.0, 0.0)], "the hidden form isn't shown");
assert!(r.warnings.iter().any(|w| w.contains("hidden layers")), "{:?}", r.warnings);
}
#[test]
fn an_encrypted_file_keeps_its_hidden_layers() {
let (objs, catalog, ids) = groups(1, "/OFF [{B}]");
let bytes = pdf_with_catalog(&[page(ids)], &[&objs[0], &objs[1]], &catalog, Some("pw"));
let r = import_with_report(&bytes, &ImportOptions { password: Some("pw".into()), ..Default::default() }).unwrap();
let d = r.document;
assert_eq!(d.layers.len(), 3, "{:?}", r.warnings);
assert!(d.layers[0].visible && !d.layers[1].visible);
assert_eq!(colors(&d.layers[1]), [Color::rgb(0.0, 0.0, 1.0)]);
}

View File

@ -65,6 +65,11 @@ pub fn first_extra(pages: usize) -> usize {
/// [`pdf`] with `extra` objects (written as given, not encrypted: streams such as functions and
/// ICC profiles that resources refer to by number, from [`first_extra`] on).
pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Vec<u8> {
pdf_with_catalog(pages, extra, "", password)
}
/// [`pdf_with`] with more entries in the catalog (`/OCProperties << … >>`).
pub fn pdf_with_catalog(pages: &[PdfPage], extra: &[&str], catalog: &str, password: Option<&str>) -> Vec<u8> {
let id = md5(b"vectorcraft test file");
// (O, U, file key) of the standard security handler, revision 2, permissions -4.
let crypt = password.map(|pw| {
@ -80,7 +85,7 @@ pub fn pdf_with(pages: &[PdfPage], extra: &[&str], password: Option<&str>) -> Ve
});
let mut objects: Vec<Vec<u8>> = vec![];
let kids: Vec<String> = (0..pages.len()).map(|i| format!("{} 0 R", 3 + 2 * i)).collect();
objects.push(b"<< /Type /Catalog /Pages 2 0 R >>".to_vec());
objects.push(format!("<< /Type /Catalog /Pages 2 0 R {catalog}>>").into_bytes());
objects.push(format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids.join(" "), pages.len()).into_bytes());
for (i, p) in pages.iter().enumerate() {
let mut d = format!("<< /Type /Page /Parent 2 0 R /MediaBox {} /Contents {} 0 R /Resources << {} >>", rect(p.media), 4 + 2 * i, p.resources);

View File

@ -235,6 +235,19 @@ and Separation and DeviceN inks become spot swatches the art links to at its tin
PostScript files (`.eps`, and `.ai` files saved in older formats or without PDF compatibility) can't be opened yet and
say so, as does an `.ai` whose PDF part is only a placeholder page.
What a PDF holds comes in as editable art: soft masks become opacity masks (an alpha mask as a white copy of its art;
the backdrop colour gives Clip, an inverting transfer function Invert), transparency groups keep isolation and knockout,
tiling patterns become pattern swatches, patch and triangle mesh shadings become gradient meshes, and gradients keep
their stop opacity and stop where the shading doesn't extend. Text becomes point type, one object per run of a line in
the file's font (by name; fonts that aren't available are listed in `warnings` and show in the fallback font) —
`textAs: "outlines"` keeps glyph outlines instead. Optional content groups (the layers of PDF and PDF-compatible `.ai`
files) become layers with their name, visibility, print state and lock, art that is off coming in as a hidden layer;
art outside them goes to a layer per page. `layers: false` gives one layer per page of only what shows:
```json
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/map.pdf","textAs":"outlines","layers":false}}}
```
Preserve Editing (`preserveEditing`, on in the `VectorCraft Default` preset) embeds the native document in the PDF as
an embedded file (`vectorcraft-editing.vectorcraft`; files named `drawcraft-editing.drawcraft` are read too) with a
hash of the pages it was written with. `open_file` / `document.open` of such a PDF, `.ai` or `.ait` (no `pages` picked) restores the